Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"cli": {
"packageManager": "npm"
"packageManager": "npm",
"analytics": false
},
"newProjectRoot": "projects",
"projects": {
Expand Down
19 changes: 19 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@angular/platform-browser": "^22.1.0",
"@angular/router": "^22.1.0",
"@tailwindcss/postcss": "^4.3.3",
"chart.js": "^4.5.1",
"keycloak-js": "^26.2.4",
"postcss": "^8.5.26",
"rxjs": "~7.8.0",
Expand Down
6 changes: 6 additions & 0 deletions src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export const routes: Routes = [
loadComponent: () =>
import('./features/dashboard/dashboard.page').then((m) => m.DashboardPage),
},
{
path: 'analytics',
title: 'Analytics · OpenTaberna Admin',
loadComponent: () =>
import('./features/analytics/analytics.page').then((m) => m.AnalyticsPage),
},
{
path: 'products',
title: 'Products · OpenTaberna Admin',
Expand Down
66 changes: 66 additions & 0 deletions src/app/core/api/analytics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { TestBed } from '@angular/core/testing';

import { AnalyticsService } from './analytics.service';
import { ApiService } from './api.service';

/**
* The analytics endpoints take `from`/`to` as inclusive calendar dates and
* reject an inverted or over-long range with a 422. These check the service
* passes through exactly what each endpoint expects, since a silently dropped
* parameter would widen the window to the API's 30-day default and quietly
* report the wrong period.
*/
describe('AnalyticsService', () => {
let calls: Array<{ path: string; params: Record<string, unknown> }>;
let service: AnalyticsService;

beforeEach(() => {
calls = [];
const apiStub = {
get: (path: string, params: Record<string, unknown>) => {
calls.push({ path, params });
return { subscribe: () => undefined };
},
};
TestBed.configureTestingModule({
providers: [AnalyticsService, { provide: ApiService, useValue: apiStub }],
});
service = TestBed.inject(AnalyticsService);
});

it('passes the requested window to the summary endpoint', () => {
service.summary({ from: '2026-08-01', to: '2026-08-31' }).subscribe();

expect(calls[0].path).toBe('/v1/admin/analytics/summary');
expect(calls[0].params['from']).toBe('2026-08-01');
expect(calls[0].params['to']).toBe('2026-08-31');
});

it('sends the bucket interval for the time series', () => {
service.timeseries({ from: '2026-01-01', to: '2026-12-31' }, 'week').subscribe();

expect(calls[0].path).toBe('/v1/admin/analytics/timeseries');
expect(calls[0].params['interval']).toBe('week');
});

it('defaults the series interval to day', () => {
service.timeseries({ from: '2026-08-01', to: '2026-08-07' }).subscribe();

expect(calls[0].params['interval']).toBe('day');
});

it('sends sort and limit for product performance', () => {
service.products({ from: '2026-08-01', to: '2026-08-31' }, 'units', 15).subscribe();

expect(calls[0].path).toBe('/v1/admin/analytics/products');
expect(calls[0].params['sort']).toBe('units');
expect(calls[0].params['limit']).toBe(15);
});

it('requests the funnel for the same window', () => {
service.funnel({ from: '2026-08-01', to: '2026-08-31' }).subscribe();

expect(calls[0].path).toBe('/v1/admin/analytics/funnel');
expect(calls[0].params['to']).toBe('2026-08-31');
});
});
55 changes: 55 additions & 0 deletions src/app/core/api/analytics.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';

import {
AnalyticsFunnel,
AnalyticsProducts,
AnalyticsSummary,
AnalyticsTimeseries,
DateRange,
ProductSort,
SeriesInterval,
} from '../models/analytics.models';
import { ApiService } from './api.service';

/**
* Commercial reporting, under `/v1/admin/analytics`.
*
* Every figure is computed by the API in SQL over the whole order history, so
* nothing here aggregates — that was the previous dashboard's mistake, and it
* capped the shop's numbers at the most recent 100 orders.
*/
@Injectable({ providedIn: 'root' })
export class AnalyticsService {
private readonly api = inject(ApiService);

/** Largest window the API accepts, in days. Beyond this it returns 422. */
static readonly MAX_RANGE_DAYS = 366 * 5;

summary(range: DateRange): Observable<AnalyticsSummary> {
return this.api.get<AnalyticsSummary>('/v1/admin/analytics/summary', { ...range });
}

timeseries(range: DateRange, interval: SeriesInterval = 'day'): Observable<AnalyticsTimeseries> {
return this.api.get<AnalyticsTimeseries>('/v1/admin/analytics/timeseries', {
...range,
interval,
});
}

products(
range: DateRange,
sort: ProductSort = 'revenue',
limit = 20,
): Observable<AnalyticsProducts> {
return this.api.get<AnalyticsProducts>('/v1/admin/analytics/products', {
...range,
sort,
limit,
});
}

funnel(range: DateRange): Observable<AnalyticsFunnel> {
return this.api.get<AnalyticsFunnel>('/v1/admin/analytics/funnel', { ...range });
}
}
33 changes: 33 additions & 0 deletions src/app/core/date-range.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { DateRange } from './models/analytics.models';

/**
* Reporting window helpers.
*
* Deliberately their own module rather than exports of the analytics page.
* The dashboard needs `rangeEndingToday` too, and importing it from a page
* component pulls that component — and through it Chart.js — into the
* dashboard's dependency graph, so a screen with no charts would pay to
* download a charting library.
*/

/**
* Local calendar date as `YYYY-MM-DD`, which is what the API expects.
*
* Built from local parts rather than `toISOString()`: the latter converts to
* UTC first, so just after midnight in any timezone ahead of UTC it reports
* yesterday, and the operator sees a window shifted by a day.
*/
export function isoDate(value: Date): string {
const year = value.getFullYear();
const month = `${value.getMonth() + 1}`.padStart(2, '0');
const day = `${value.getDate()}`.padStart(2, '0');
return `${year}-${month}-${day}`;
}

/** The inclusive window ending today, `days` long. */
export function rangeEndingToday(days: number, today = new Date()): DateRange {
const start = new Date(today);
// Inclusive of both ends, so a 7 day window spans today and the six before.
start.setDate(start.getDate() - (days - 1));
return { from: isoDate(start), to: isoDate(today) };
}
144 changes: 144 additions & 0 deletions src/app/core/models/analytics.models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Analytics API types, mirroring `/v1/admin/analytics`.
*
* Two conventions carried over from the API and worth not undoing here:
*
* **Money is integer minor units.** Render with `MoneyPipe`; never divide by
* 100 by hand.
*
* **Money is grouped by currency.** The API returns a list keyed by currency
* because `orders.currency` permits several and a cross-currency total means
* nothing. Adding these together in the UI would reintroduce exactly the bug
* the API shape exists to prevent.
*/

export interface PeriodInfo {
start: string;
end: string;
timezone: string;
days: number;
}

/**
* Movement against the previous period.
*
* Every field is nullable: change from a baseline of zero is undefined, not
* infinite and not 100%. Render null as "no prior data", never as a number.
*/
export interface AnalyticsChange {
net_revenue_pct: number | null;
gross_revenue_pct: number | null;
orders_pct: number | null;
units_pct: number | null;
average_order_value_pct: number | null;
}

export interface CurrencyTotalsPrevious {
gross_revenue: number;
refunded_revenue: number;
net_revenue: number;
orders: number;
units: number;
average_order_value: number;
}

export interface CurrencyTotals {
currency: string;
gross_revenue: number;
refunded_revenue: number;
net_revenue: number;
orders: number;
units: number;
average_order_value: number;
previous: CurrencyTotalsPrevious | null;
change: AnalyticsChange | null;
}

export interface AnalyticsSummary {
period: PeriodInfo;
previous_period: PeriodInfo;
currencies: CurrencyTotals[];
}

export interface SeriesPoint {
bucket: string;
gross_revenue: number;
refunded_revenue: number;
net_revenue: number;
orders: number;
units: number;
}

export interface CurrencySeries {
currency: string;
points: SeriesPoint[];
}

export interface AnalyticsTimeseries {
period: PeriodInfo;
interval: string;
series: CurrencySeries[];
}

export interface ProductPerformance {
sku: string;
name: string | null;
currency: string;
units_sold: number;
gross_revenue: number;
orders: number;
/**
* Orders containing this SKU where a return was raised. Returns are recorded
* per order, so this attributes one return to every SKU on that order — an
* upper bound per SKU, not a per-item rate.
*/
orders_with_return: number;
return_rate: number | null;
}

export interface NeverSoldItem {
sku: string;
name: string;
status: string;
on_hand: number | null;
}

export interface AnalyticsProducts {
period: PeriodInfo;
sort: string;
products: ProductPerformance[];
never_sold: NeverSoldItem[];
}

export interface FunnelStep {
step: string;
label: string;
orders: number;
conversion_from_start: number | null;
drop_off_from_previous: number | null;
}

/**
* Where orders stop.
*
* An **order** funnel, not a visitor funnel: it begins at order creation and
* cannot see shoppers who browsed without ordering. Label it accordingly —
* calling this "conversion" would promise something it does not measure.
*/
export interface AnalyticsFunnel {
period: PeriodInfo;
steps: FunnelStep[];
never_checked_out: number;
payment_failed: number;
payment_unresolved: number;
cancelled: number;
}

export type SeriesInterval = 'day' | 'week' | 'month';
export type ProductSort = 'revenue' | 'units' | 'orders' | 'return_rate';

/** A selectable reporting window, as inclusive calendar dates. */
export interface DateRange {
from: string;
to: string;
}
Loading
Loading