diff --git a/angular.json b/angular.json index 05ee946..57ef391 100644 --- a/angular.json +++ b/angular.json @@ -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": { diff --git a/package-lock.json b/package-lock.json index 5a9732d..a378b30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,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", @@ -2069,6 +2070,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-4.2.4.tgz", @@ -4744,6 +4751,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/chokidar": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", diff --git a/package.json b/package.json index fab968e..3dd317a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index b91b1cb..3186076 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -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', diff --git a/src/app/core/api/analytics.service.spec.ts b/src/app/core/api/analytics.service.spec.ts new file mode 100644 index 0000000..2a3e63a --- /dev/null +++ b/src/app/core/api/analytics.service.spec.ts @@ -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 }>; + let service: AnalyticsService; + + beforeEach(() => { + calls = []; + const apiStub = { + get: (path: string, params: Record) => { + 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'); + }); +}); diff --git a/src/app/core/api/analytics.service.ts b/src/app/core/api/analytics.service.ts new file mode 100644 index 0000000..b90b9fb --- /dev/null +++ b/src/app/core/api/analytics.service.ts @@ -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 { + return this.api.get('/v1/admin/analytics/summary', { ...range }); + } + + timeseries(range: DateRange, interval: SeriesInterval = 'day'): Observable { + return this.api.get('/v1/admin/analytics/timeseries', { + ...range, + interval, + }); + } + + products( + range: DateRange, + sort: ProductSort = 'revenue', + limit = 20, + ): Observable { + return this.api.get('/v1/admin/analytics/products', { + ...range, + sort, + limit, + }); + } + + funnel(range: DateRange): Observable { + return this.api.get('/v1/admin/analytics/funnel', { ...range }); + } +} diff --git a/src/app/core/date-range.ts b/src/app/core/date-range.ts new file mode 100644 index 0000000..0474d58 --- /dev/null +++ b/src/app/core/date-range.ts @@ -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) }; +} diff --git a/src/app/core/models/analytics.models.ts b/src/app/core/models/analytics.models.ts new file mode 100644 index 0000000..618ed4d --- /dev/null +++ b/src/app/core/models/analytics.models.ts @@ -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; +} diff --git a/src/app/features/analytics/analytics.page.html b/src/app/features/analytics/analytics.page.html new file mode 100644 index 0000000..67f2946 --- /dev/null +++ b/src/app/features/analytics/analytics.page.html @@ -0,0 +1,328 @@ +
+ +
+
+

Analytics

+

+ Computed over the whole order history, not a sample. +

+
+ +
+ @for (range of ranges; track range.days) { + + } +
+
+ + @if (partial()) { +
+ Some panels could not be loaded. What is shown below is incomplete. +
+ } + + @if (loading()) { +
+ } @else { + + @if (currencies().length === 0) { + +

No orders in this period.

+
+ } + + @for (totals of currencies(); track totals.currency) { + +
+
+
Net revenue
+
+ {{ totals.net_revenue | money: totals.currency }} +
+
+ + {{ trend(totals.change?.net_revenue_pct) }} + {{ + totals.change?.net_revenue_pct === null || + totals.change?.net_revenue_pct === undefined + ? 'no prior data' + : totals.change?.net_revenue_pct + '%' + }} + +
+
+ +
+
Gross revenue
+
+ {{ totals.gross_revenue | money: totals.currency }} +
+
+ less {{ totals.refunded_revenue | money: totals.currency }} refunded +
+
+ +
+
Orders
+
{{ totals.orders }}
+
+ + {{ trend(totals.change?.orders_pct) }} + {{ + totals.change?.orders_pct === null || totals.change?.orders_pct === undefined + ? 'no prior data' + : totals.change?.orders_pct + '%' + }} + +
+
+ +
+
Average order
+
+ {{ totals.average_order_value | money: totals.currency }} +
+
{{ totals.units }} units sold
+
+ +
+
Previous period
+
+ {{ totals.previous?.net_revenue | money: totals.currency }} +
+
{{ totals.previous?.orders ?? 0 }} orders
+
+
+
+ } + + @if (multiCurrency()) { +
+ The shop traded in {{ currencies().length }} currencies this period. Charts below show + {{ primaryCurrency() }} only — totals are + never summed across currencies, because a combined figure would mean nothing. +
+ } + + +
+ + @if (revenueChart(); as config) { + + } @else { +

No revenue in this period.

+ } +
+ + + @if (ordersChart(); as config) { + + } @else { +

No orders in this period.

+ } +
+
+ + + @if (funnel(); as data) { + +
+
+ @if (funnelChart(); as config) { + + } +
+ +
+ + + + + + + + + + + @for (step of data.steps; track step.step) { + + + + + + + } + +
StepOrdersOf createdLost
{{ step.label }}{{ step.orders }} + {{ percent(step.conversion_from_start) }} + + @if (step.drop_off_from_previous) { + −{{ step.drop_off_from_previous }} + } @else { + — + } +
+ +
+
+
Never checked out
+
{{ data.never_checked_out }}
+
+
+
Payment failed
+
{{ data.payment_failed }}
+
+
+
Payment unresolved
+
{{ data.payment_unresolved }}
+
+
+
Cancelled
+
{{ data.cancelled }}
+
+
+ + @if (worstDropOff(); as worst) { +

+ Biggest loss is at {{ worst.label }}, where {{ worst.drop_off_from_previous }} orders stop. +

+ } +
+
+
+ } + + + @if (products(); as data) { + +
+
+

Product performance

+

+ Line revenue, which may differ from order totals carrying shipping. +

+
+
+ @for (option of ['revenue', 'units', 'orders', 'return_rate']; track option) { + + } +
+
+ + @if (data.products.length === 0) { +

Nothing sold in this period.

+ } @else { +
+ + + + + + + + + + + + @for (product of data.products; track product.sku + product.currency) { + + + + + + + + } + +
ProductUnitsRevenueOrdersReturns
+ {{ product.name ?? product.sku }} + {{ + product.sku + }} + {{ product.units_sold }} + {{ product.gross_revenue | money: product.currency }} + {{ product.orders }} + @if (product.orders_with_return > 0) { + {{ percent(product.return_rate) }} + } @else { + — + } +
+
+

+ Returns are recorded per order, not per line, so a return counts against every product + on that order. Treat the rate as an upper bound. +

+ } +
+ + @if (data.never_sold.length > 0) { + +
    + @for (item of data.never_sold; track item.sku) { +
  • + + {{ item.name }} + {{ + item.sku + }} + + + @if (item.on_hand !== null) { + {{ item.on_hand }} in stock + } @else { + stock untracked + } + +
  • + } +
+
+ } + } + } +
diff --git a/src/app/features/analytics/analytics.page.spec.ts b/src/app/features/analytics/analytics.page.spec.ts new file mode 100644 index 0000000..f9f4691 --- /dev/null +++ b/src/app/features/analytics/analytics.page.spec.ts @@ -0,0 +1,208 @@ +import { TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; + +import { AnalyticsService } from '../../core/api/analytics.service'; +import { isoDate, rangeEndingToday } from '../../core/date-range'; +import { AnalyticsPage } from './analytics.page'; + +/** + * The window helpers decide which period every figure on the page describes, + * so an off-by-one here misreports the shop's takings rather than merely + * looking wrong. + */ +describe('rangeEndingToday', () => { + it('includes both ends, so seven days spans today and the six before', () => { + const range = rangeEndingToday(7, new Date(2026, 7, 26)); + + expect(range.to).toBe('2026-08-26'); + expect(range.from).toBe('2026-08-20'); + }); + + it('treats a one day window as today only', () => { + const range = rangeEndingToday(1, new Date(2026, 7, 26)); + + expect(range.from).toBe('2026-08-26'); + expect(range.to).toBe('2026-08-26'); + }); + + it('crosses a month boundary correctly', () => { + const range = rangeEndingToday(7, new Date(2026, 8, 3)); + + expect(range.from).toBe('2026-08-28'); + expect(range.to).toBe('2026-09-03'); + }); +}); + +describe('isoDate', () => { + it('uses local calendar date, not UTC', () => { + // 1 March 2026 at 00:30 local. Formatting via toISOString() would emit + // the previous day for any timezone ahead of UTC. + expect(isoDate(new Date(2026, 2, 1, 0, 30))).toBe('2026-03-01'); + }); + + it('zero-pads month and day', () => { + expect(isoDate(new Date(2026, 0, 5))).toBe('2026-01-05'); + }); +}); + +/** + * Renders the page against stubbed responses. + * + * This is a smoke test with teeth: it compiles the template, runs every + * computed and builds real Chart.js configurations, so a mistyped field or a + * null that reaches an arithmetic path fails here rather than on the operator's + * screen. + */ +describe('AnalyticsPage', () => { + const EUR = { + currency: 'EUR', + gross_revenue: 50000, + refunded_revenue: 10000, + net_revenue: 40000, + orders: 4, + units: 9, + average_order_value: 12500, + previous: { + gross_revenue: 25000, + refunded_revenue: 0, + net_revenue: 25000, + orders: 2, + units: 4, + average_order_value: 12500, + }, + change: { + net_revenue_pct: 60, + gross_revenue_pct: 100, + orders_pct: 100, + units_pct: 125, + average_order_value_pct: 0, + }, + }; + + const USD = { ...EUR, currency: 'USD', gross_revenue: 1000, net_revenue: 900 }; + + function build(overrides: Record = {}) { + TestBed.configureTestingModule({ + providers: [ + { + provide: AnalyticsService, + useValue: { + summary: () => of({ currencies: [EUR], period: {}, previous_period: {} }), + timeseries: () => + of({ + interval: 'day', + series: [ + { + currency: 'EUR', + points: [ + { + bucket: '2026-08-01', + gross_revenue: 20000, + refunded_revenue: 0, + net_revenue: 20000, + orders: 2, + units: 4, + }, + { + bucket: '2026-08-02', + gross_revenue: 0, + refunded_revenue: 0, + net_revenue: 0, + orders: 0, + units: 0, + }, + ], + }, + ], + }), + products: () => of({ sort: 'revenue', products: [], never_sold: [] }), + funnel: () => + of({ + steps: [ + { + step: 'created', + label: 'Order created', + orders: 10, + conversion_from_start: 1, + drop_off_from_previous: null, + }, + { + step: 'checkout_started', + label: 'Checkout started', + orders: 6, + conversion_from_start: 0.6, + drop_off_from_previous: 4, + }, + { + step: 'paid', + label: 'Payment confirmed', + orders: 5, + conversion_from_start: 0.5, + drop_off_from_previous: 1, + }, + ], + never_checked_out: 4, + payment_failed: 1, + payment_unresolved: 0, + cancelled: 2, + }), + ...overrides, + }, + }, + ], + }); + const fixture = TestBed.createComponent(AnalyticsPage); + fixture.detectChanges(); + return fixture.componentInstance; + } + + it('renders without throwing and finishes loading', () => { + const page = build(); + expect(page.loading()).toBe(false); + expect(page.partial()).toBe(false); + }); + + it('builds chart configurations from the series', () => { + const page = build(); + const revenue = page.revenueChart(); + + expect(revenue?.type).toBe('line'); + expect(revenue?.data.labels).toEqual(['2026-08-01', '2026-08-02']); + expect(revenue?.data.datasets[0].data).toEqual([20000, 0]); + }); + + it('picks the currency that earned most for the charts', () => { + const page = build({ + summary: () => of({ currencies: [USD, EUR], period: {}, previous_period: {} }), + }); + + expect(page.primaryCurrency()).toBe('EUR'); + expect(page.multiCurrency()).toBe(true); + }); + + it('names the step where most orders are lost', () => { + const page = build(); + expect(page.worstDropOff()?.step).toBe('checkout_started'); + }); + + it('flags a partial view when one panel fails', () => { + const page = build({ funnel: () => of(null) }); + expect(page.partial()).toBe(true); + }); + + it('shows an em dash rather than an arrow when there is no baseline', () => { + const page = build(); + // Change from zero is undefined; an arrow would assert a direction the + // data cannot support. + expect(page.trend(null)).toBe('—'); + expect(page.trendTone(null)).toBe('neutral'); + expect(page.trend(12)).toBe('↑'); + expect(page.trend(-12)).toBe('↓'); + }); + + it('renders a null conversion as an em dash, not 0%', () => { + const page = build(); + expect(page.percent(null)).toBe('—'); + expect(page.percent(0.6)).toBe('60.0%'); + }); +}); diff --git a/src/app/features/analytics/analytics.page.ts b/src/app/features/analytics/analytics.page.ts new file mode 100644 index 0000000..a22f329 --- /dev/null +++ b/src/app/features/analytics/analytics.page.ts @@ -0,0 +1,258 @@ +import { CommonModule } from '@angular/common'; +import { Component, computed, inject, signal } from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { ChartConfiguration } from 'chart.js'; +import { forkJoin, of } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { AnalyticsService } from '../../core/api/analytics.service'; +import { rangeEndingToday } from '../../core/date-range'; +import { + AnalyticsFunnel, + AnalyticsProducts, + AnalyticsSummary, + AnalyticsTimeseries, + CurrencyTotals, + ProductSort, + SeriesInterval, +} from '../../core/models/analytics.models'; +import { BRAND, BRAND_SOFT, ChartComponent, DANGER, MUTED, baseOptions } from '../../shared/charts'; +import { BadgeComponent, CardComponent, MoneyPipe, SpinnerComponent } from '../../shared/ui'; + +/** Selectable windows, in days. */ +const RANGES = [ + { label: '7 days', days: 7 }, + { label: '30 days', days: 30 }, + { label: '90 days', days: 90 }, + { label: '12 months', days: 365 }, +] as const; + +/** + * Commercial analytics: what the shop took, what sold, and where orders stop. + * + * Every figure comes from `/v1/admin/analytics`, computed in SQL over the whole + * order history. Nothing is aggregated here — the previous dashboard did that + * client-side and could only ever see the most recent 100 orders. + * + * Money is shown per currency and never summed across them. The API returns a + * list keyed by currency precisely because a cross-currency total is + * meaningless; adding them up in the UI would put the bug back. + * + * Each panel degrades on its own, so one failing request leaves the rest of the + * page readable. + */ +@Component({ + selector: 'ot-analytics-page', + standalone: true, + imports: [ + CommonModule, + FormsModule, + CardComponent, + BadgeComponent, + SpinnerComponent, + ChartComponent, + MoneyPipe, + ], + templateUrl: './analytics.page.html', +}) +export class AnalyticsPage { + private readonly analytics = inject(AnalyticsService); + + readonly ranges = RANGES; + readonly selectedDays = signal(30); + readonly interval = signal('day'); + readonly sort = signal('revenue'); + + readonly loading = signal(true); + readonly partial = signal(false); + + readonly summary = signal(null); + readonly series = signal(null); + readonly products = signal(null); + readonly funnel = signal(null); + + /** Currency the charts are drawn in — the one with the most revenue. */ + readonly primaryCurrency = computed(() => { + const totals = this.summary()?.currencies ?? []; + if (totals.length === 0) { + return 'EUR'; + } + return [...totals].sort((a, b) => b.gross_revenue - a.gross_revenue)[0].currency; + }); + + readonly currencies = computed(() => this.summary()?.currencies ?? []); + + /** + * True when more than one currency traded. The charts show one currency, so + * the page has to say so rather than let a reader assume it is everything. + */ + readonly multiCurrency = computed(() => this.currencies().length > 1); + + readonly hasRevenue = computed(() => this.currencies().some((entry) => entry.gross_revenue > 0)); + + readonly revenueChart = computed | null>(() => { + const currency = this.primaryCurrency(); + const points = this.series()?.series.find((s) => s.currency === currency)?.points; + if (!points?.length) { + return null; + } + return { + type: 'line', + data: { + labels: points.map((point) => point.bucket), + datasets: [ + { + label: 'Net revenue', + data: points.map((point) => point.net_revenue), + borderColor: BRAND, + backgroundColor: BRAND_SOFT, + fill: true, + tension: 0.25, + pointRadius: points.length > 60 ? 0 : 2, + }, + ], + }, + options: baseOptions(currency) as ChartConfiguration<'line'>['options'], + }; + }); + + readonly ordersChart = computed | null>(() => { + const currency = this.primaryCurrency(); + const points = this.series()?.series.find((s) => s.currency === currency)?.points; + if (!points?.length) { + return null; + } + const options = baseOptions(currency) as ChartConfiguration<'bar'>['options']; + return { + type: 'bar', + data: { + labels: points.map((point) => point.bucket), + datasets: [ + { + label: 'Orders', + data: points.map((point) => point.orders), + backgroundColor: BRAND, + borderRadius: 3, + }, + { + label: 'Units', + data: points.map((point) => point.units), + backgroundColor: MUTED, + borderRadius: 3, + }, + ], + }, + options: { + ...options, + scales: { + ...options?.scales, + // Counts, not money — the shared money formatter must not apply. + y: { beginAtZero: true, ticks: { precision: 0 } }, + }, + }, + }; + }); + + readonly funnelChart = computed | null>(() => { + const steps = this.funnel()?.steps; + if (!steps?.length) { + return null; + } + return { + type: 'bar', + data: { + labels: steps.map((step) => step.label), + datasets: [ + { + label: 'Orders', + data: steps.map((step) => step.orders), + backgroundColor: [BRAND, BRAND, BRAND, BRAND].slice(0, steps.length), + borderRadius: 3, + }, + ], + }, + options: { + indexAxis: 'y', + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false } }, + scales: { x: { beginAtZero: true, ticks: { precision: 0 } } }, + }, + }; + }); + + readonly worstDropOff = computed(() => { + const steps = this.funnel()?.steps ?? []; + const withDrop = steps.filter((step) => (step.drop_off_from_previous ?? 0) > 0); + if (withDrop.length === 0) { + return null; + } + return withDrop.reduce((worst, step) => + (step.drop_off_from_previous ?? 0) > (worst.drop_off_from_previous ?? 0) ? step : worst, + ); + }); + + constructor() { + this.load(); + } + + selectRange(days: number): void { + this.selectedDays.set(days); + // Daily buckets over a year are unreadable and slow to draw. + this.interval.set(days > 120 ? 'week' : 'day'); + this.load(); + } + + selectSort(sort: ProductSort): void { + this.sort.set(sort); + this.load(); + } + + private load(): void { + this.loading.set(true); + this.partial.set(false); + + const range = rangeEndingToday(this.selectedDays()); + + forkJoin({ + summary: this.analytics.summary(range).pipe(catchError(() => of(null))), + series: this.analytics.timeseries(range, this.interval()).pipe(catchError(() => of(null))), + products: this.analytics.products(range, this.sort(), 15).pipe(catchError(() => of(null))), + funnel: this.analytics.funnel(range).pipe(catchError(() => of(null))), + }).subscribe((result) => { + if (!result.summary || !result.series || !result.products || !result.funnel) { + this.partial.set(true); + } + this.summary.set(result.summary); + this.series.set(result.series); + this.products.set(result.products); + this.funnel.set(result.funnel); + this.loading.set(false); + }); + } + + /** + * Arrow for a percentage change, or an em dash when there is no baseline. + * + * Change from zero is undefined; showing 0% or an arrow would assert + * something the data does not support. + */ + trend(pct: number | null | undefined): '↑' | '↓' | '—' { + if (pct === null || pct === undefined || pct === 0) { + return '—'; + } + return pct > 0 ? '↑' : '↓'; + } + + trendTone(pct: number | null | undefined, higherIsBetter = true): 'ok' | 'danger' | 'neutral' { + if (pct === null || pct === undefined || pct === 0) { + return 'neutral'; + } + const good = higherIsBetter ? pct > 0 : pct < 0; + return good ? 'ok' : 'danger'; + } + + percent(value: number | null | undefined): string { + return value === null || value === undefined ? '—' : `${(value * 100).toFixed(1)}%`; + } +} diff --git a/src/app/features/dashboard/dashboard.page.html b/src/app/features/dashboard/dashboard.page.html index 60d8b80..6fcb710 100644 --- a/src/app/features/dashboard/dashboard.page.html +++ b/src/app/features/dashboard/dashboard.page.html @@ -8,11 +8,16 @@
-

Revenue

+

Net revenue

{{ revenue() | money: currency() }}

-

Paid, packed and shipped

+

+ Last {{ moneyWindowDays }} days, after refunds + @if (multiCurrency()) { + · {{ currency() }} only + } +

{{ averageOrder() | money: currency() }}

-

{{ shipped().length }} shipped to date

+

Across {{ revenueOrders() }} paid order(s)

@@ -137,6 +142,7 @@ + @if (statusMix().length === 0) {

No orders yet.

} @else { diff --git a/src/app/features/dashboard/dashboard.page.spec.ts b/src/app/features/dashboard/dashboard.page.spec.ts new file mode 100644 index 0000000..dab58b5 --- /dev/null +++ b/src/app/features/dashboard/dashboard.page.spec.ts @@ -0,0 +1,118 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter } from '@angular/router'; +import { of } from 'rxjs'; + +import { AnalyticsService } from '../../core/api/analytics.service'; +import { CatalogueService } from '../../core/api/catalogue.service'; +import { InventoryService } from '../../core/api/inventory.service'; +import { OrdersService } from '../../core/api/orders.service'; +import { DashboardPage } from './dashboard.page'; + +/** + * The dashboard used to sum revenue from the orders list, which meant it only + * ever saw the most recent 100 orders. These pin the figures to the analytics + * endpoint instead — the whole point of OpenTaberna/admin_frontend#7. + * + * The orders stub deliberately carries values that would produce a *different* + * total if anything went back to adding them up, so a regression cannot pass + * by coincidence. + */ +describe('DashboardPage money figures', () => { + function configure(summaryCurrencies: unknown[]) { + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { + provide: AnalyticsService, + useValue: { + summary: () => of({ currencies: summaryCurrencies }), + }, + }, + { + provide: OrdersService, + useValue: { + list: () => + of({ + orders: [ + // 999999 would dominate any client-side sum. + { + status: 'paid', + total_amount: 999999, + currency: 'EUR', + created_at: '2026-08-01T00:00:00Z', + }, + { + status: 'pending_payment', + total_amount: 5000, + currency: 'EUR', + created_at: '2026-08-02T00:00:00Z', + }, + ], + }), + }, + }, + { provide: CatalogueService, useValue: { list: () => of({ items: [] }) } }, + { provide: InventoryService, useValue: { list: () => of({ items: [] }) } }, + ], + }); + return TestBed.createComponent(DashboardPage).componentInstance; + } + + const EUR = { + currency: 'EUR', + gross_revenue: 50000, + refunded_revenue: 10000, + net_revenue: 40000, + orders: 4, + units: 9, + average_order_value: 12500, + previous: null, + change: null, + }; + + it('reports net revenue from the API, not from the orders list', () => { + const page = configure([EUR]); + + expect(page.revenue()).toBe(40000); + expect(page.revenue()).not.toBe(999999); + }); + + it('takes average order value from the API rather than recomputing it', () => { + const page = configure([EUR]); + + expect(page.averageOrder()).toBe(12500); + }); + + it('reports how many orders the revenue covers', () => { + const page = configure([EUR]); + + expect(page.revenueOrders()).toBe(4); + }); + + it('shows the currency that earned the most, never a sum across them', () => { + const page = configure([ + { ...EUR, currency: 'USD', gross_revenue: 10000, net_revenue: 9000 }, + EUR, + ]); + + expect(page.currency()).toBe('EUR'); + expect(page.multiCurrency()).toBe(true); + // 40000, not 49000 — currencies are never added together. + expect(page.revenue()).toBe(40000); + }); + + it('degrades to zero and flags a partial view when analytics fails', () => { + const page = configure([]); + + expect(page.revenue()).toBe(0); + expect(page.multiCurrency()).toBe(false); + }); + + it('still derives work queues from the orders list', () => { + const page = configure([EUR]); + + // Operational panels are meant to read the newest orders. + expect(page.awaitingPayment().length).toBe(1); + expect(page.awaitingPaymentValue()).toBe(5000); + }); +}); diff --git a/src/app/features/dashboard/dashboard.page.ts b/src/app/features/dashboard/dashboard.page.ts index 52dfcef..88fbc0f 100644 --- a/src/app/features/dashboard/dashboard.page.ts +++ b/src/app/features/dashboard/dashboard.page.ts @@ -4,22 +4,29 @@ import { RouterLink } from '@angular/router'; import { forkJoin, of } from 'rxjs'; import { catchError } from 'rxjs/operators'; +import { AnalyticsService } from '../../core/api/analytics.service'; import { CatalogueService } from '../../core/api/catalogue.service'; import { InventoryService } from '../../core/api/inventory.service'; import { OrdersService } from '../../core/api/orders.service'; +import { CurrencyTotals } from '../../core/models/analytics.models'; import { InventoryItem, Item, OrderSummary } from '../../core/models/api.models'; +import { rangeEndingToday } from '../../core/date-range'; import { BadgeComponent, CardComponent, MoneyPipe, SpinnerComponent } from '../../shared/ui'; -/** Order states whose value counts as money actually taken. */ -const EARNED: readonly string[] = ['paid', 'ready_to_ship', 'shipped']; +/** Window the money tiles report on. Deep analysis lives on /analytics. */ +const MONEY_WINDOW_DAYS = 30; /** * The operational picture: money, payments, shipping and stock. * - * Every figure is derived from the orders list rather than a stats endpoint, - * because the API has none yet. That is honest but has a limit worth knowing: - * it reflects the most recent 100 orders, which is stated on the page rather - * than quietly implied. + * Money comes from `/v1/admin/analytics/summary`, computed in SQL over the + * whole order history. It used to be summed here from the most recent 100 + * orders, which meant the headline revenue silently stopped being the shop's + * revenue at order 101. + * + * The work queues below still read the orders list, and that is the right + * source for them: they are "what needs doing now", and the newest orders are + * exactly the ones that need doing. * * Each panel degrades on its own — one failing endpoint must not blank the * page, so a partial view is shown with a warning instead. @@ -31,6 +38,7 @@ const EARNED: readonly string[] = ['paid', 'ready_to_ship', 'shipped']; templateUrl: './dashboard.page.html', }) export class DashboardPage { + private readonly analytics = inject(AnalyticsService); private readonly catalogue = inject(CatalogueService); private readonly inventory = inject(InventoryService); private readonly orders = inject(OrdersService); @@ -40,16 +48,37 @@ export class DashboardPage { readonly items = signal([]); readonly stock = signal([]); readonly allOrders = signal([]); + readonly totals = signal([]); + + readonly moneyWindowDays = MONEY_WINDOW_DAYS; + + /** + * The currency the tiles are shown in: whichever earned most. + * + * Falls back to the newest order's currency when the analytics call failed, + * so the page still renders something sensible rather than a bare number. + */ + readonly currency = computed(() => { + const ranked = [...this.totals()].sort((a, b) => b.gross_revenue - a.gross_revenue); + return ranked[0]?.currency ?? this.allOrders()[0]?.currency ?? 'EUR'; + }); - readonly currency = computed(() => this.allOrders()[0]?.currency ?? 'EUR'); - - /** Money taken: paid, packed or shipped. */ - readonly revenue = computed(() => - this.allOrders() - .filter((o) => EARNED.includes(o.status)) - .reduce((sum, o) => sum + o.total_amount, 0), + /** Headline block for the displayed currency, if analytics answered. */ + private readonly primaryTotals = computed(() => + this.totals().find((entry) => entry.currency === this.currency()), ); + /** + * True when more than one currency traded, so the tiles can say they show + * one of them. Summing across currencies is never correct. + */ + readonly multiCurrency = computed(() => this.totals().length > 1); + + /** Money taken in the window, net of refunds. */ + readonly revenue = computed(() => this.primaryTotals()?.net_revenue ?? 0); + + readonly revenueOrders = computed(() => this.primaryTotals()?.orders ?? 0); + /** Checkouts started but not paid — revenue at risk, not revenue earned. */ readonly awaitingPayment = computed(() => this.allOrders().filter((o) => o.status === 'pending_payment'), @@ -79,10 +108,7 @@ export class DashboardPage { ); /** Average order value across orders that produced money. */ - readonly averageOrder = computed(() => { - const earned = this.allOrders().filter((o) => EARNED.includes(o.status)); - return earned.length === 0 ? 0 : Math.round(this.revenue() / earned.length); - }); + readonly averageOrder = computed(() => this.primaryTotals()?.average_order_value ?? 0); readonly activeCount = computed(() => this.items().filter((i) => i.status === 'active').length); readonly hiddenCount = computed(() => this.items().filter((i) => i.status !== 'active').length); @@ -124,13 +150,17 @@ export class DashboardPage { items: this.catalogue.list(0, 100).pipe(catchError(() => of(null))), stock: this.inventory.list(0, 200).pipe(catchError(() => of(null))), orders: this.orders.list(undefined, 0, 100).pipe(catchError(() => of(null))), + summary: this.analytics + .summary(rangeEndingToday(MONEY_WINDOW_DAYS)) + .pipe(catchError(() => of(null))), }).subscribe((res) => { - if (!res.items || !res.stock || !res.orders) { + if (!res.items || !res.stock || !res.orders || !res.summary) { this.partial.set(true); } this.items.set(res.items?.items ?? []); this.stock.set(res.stock?.items ?? []); this.allOrders.set(res.orders?.orders ?? []); + this.totals.set(res.summary?.currencies ?? []); this.loading.set(false); }); } diff --git a/src/app/layout/nav.model.spec.ts b/src/app/layout/nav.model.spec.ts index c647893..37a2b1d 100644 --- a/src/app/layout/nav.model.spec.ts +++ b/src/app/layout/nav.model.spec.ts @@ -1,9 +1,10 @@ import { NAV_ITEMS } from './nav.model'; describe('side navigation', () => { - it('exposes the six administration areas', () => { + it('exposes the seven administration areas, in working order', () => { expect(NAV_ITEMS.map((i) => i.path)).toEqual([ '/dashboard', + '/analytics', '/products', '/inventory', '/orders', diff --git a/src/app/layout/nav.model.ts b/src/app/layout/nav.model.ts index 05aa95e..2eecf0c 100644 --- a/src/app/layout/nav.model.ts +++ b/src/app/layout/nav.model.ts @@ -19,6 +19,12 @@ export const NAV_ITEMS: readonly NavItem[] = [ description: 'Overview', icon: 'M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z', }, + { + label: 'Analytics', + path: '/analytics', + description: 'Revenue, products and funnel', + icon: 'M3 3v16a2 2 0 0 0 2 2h16M7 15l3.5-4 3 2.5L20 7', + }, { label: 'Products', path: '/products', diff --git a/src/app/shared/charts/chart-options.ts b/src/app/shared/charts/chart-options.ts new file mode 100644 index 0000000..f52ac05 --- /dev/null +++ b/src/app/shared/charts/chart-options.ts @@ -0,0 +1,67 @@ +import { ChartConfiguration } from 'chart.js'; + +/** + * Shared chart styling. + * + * Kept beside the wrapper rather than repeated per page so every chart in the + * back office reads the same way, and so the money formatter is defined once — + * amounts arrive in minor units and dividing by 100 in each chart is exactly + * how one of them ends up a hundred times too large. + */ + +const INK = '#334155'; +const INK_SUBTLE = '#94a3b8'; +const LINE = '#e2e8f0'; + +export const BRAND = '#2563eb'; +export const BRAND_SOFT = 'rgba(37, 99, 235, 0.12)'; +export const DANGER = '#dc2626'; +export const MUTED = '#94a3b8'; + +/** Format an integer minor-unit amount for an axis or tooltip. */ +export function formatMoney(minorUnits: number, currency: string): string { + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency, + maximumFractionDigits: 0, + }).format(minorUnits / 100); +} + +/** Axis and tooltip defaults shared by every chart on the analytics page. */ +export function baseOptions(currency: string): ChartConfiguration['options'] { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: 'index', intersect: false }, + plugins: { + legend: { + display: true, + position: 'bottom', + labels: { color: INK, boxWidth: 12, boxHeight: 12, usePointStyle: true }, + }, + tooltip: { + callbacks: { + label: (context) => { + const value = context.parsed.y ?? 0; + const isMoney = /revenue/i.test(context.dataset.label ?? ''); + return `${context.dataset.label}: ${isMoney ? formatMoney(value, currency) : value}`; + }, + }, + }, + }, + scales: { + x: { + grid: { display: false }, + ticks: { color: INK_SUBTLE, maxRotation: 0, autoSkipPadding: 16 }, + }, + y: { + beginAtZero: true, + grid: { color: LINE }, + ticks: { + color: INK_SUBTLE, + callback: (value) => formatMoney(Number(value), currency), + }, + }, + }, + }; +} diff --git a/src/app/shared/charts/chart.ts b/src/app/shared/charts/chart.ts new file mode 100644 index 0000000..16d1ca8 --- /dev/null +++ b/src/app/shared/charts/chart.ts @@ -0,0 +1,85 @@ +import { Component, ElementRef, OnDestroy, ViewChild, effect, input } from '@angular/core'; +import { + BarController, + BarElement, + CategoryScale, + Chart, + ChartConfiguration, + ChartType, + Filler, + Legend, + LineController, + LineElement, + LinearScale, + PointElement, + Tooltip, +} from 'chart.js'; + +/** + * Register only what the dashboard draws. + * + * Chart.js ships every controller, scale and plugin; importing the `auto` + * bundle pulls all of them into the build. Registering explicitly keeps radar, + * polar, doughnut and the rest out of the bundle. + */ +Chart.register( + BarController, + BarElement, + CategoryScale, + Filler, + Legend, + LineController, + LineElement, + LinearScale, + PointElement, + Tooltip, +); + +/** + * A canvas chart. + * + * Deliberately thin, and deliberately not an Angular wrapper library. The app + * runs zoneless and tracks the newest Angular, and a third-party wrapper adds + * a dependency whose peer range gates every future Angular upgrade. Chart.js + * itself has no opinion about the framework. + * + * Redraws whenever `config` changes: the chart is destroyed and rebuilt rather + * than mutated, because partial updates across a changing dataset shape are a + * reliable source of stale axes and orphaned tooltips. + */ +@Component({ + selector: 'ot-chart', + standalone: true, + template: ` +
+ +
+ `, +}) +export class ChartComponent implements OnDestroy { + readonly config = input.required>(); + readonly height = input(280); + /** Charts are images to a screen reader; the summary table carries the data. */ + readonly label = input('Chart'); + + @ViewChild('canvas') private canvasRef?: ElementRef; + + private chart?: Chart; + + constructor() { + effect(() => { + const config = this.config(); + // Read after config so the effect re-runs when the canvas first appears. + const canvas = this.canvasRef?.nativeElement; + if (!canvas) { + return; + } + this.chart?.destroy(); + this.chart = new Chart(canvas, config); + }); + } + + ngOnDestroy(): void { + this.chart?.destroy(); + } +} diff --git a/src/app/shared/charts/index.ts b/src/app/shared/charts/index.ts new file mode 100644 index 0000000..f4bb011 --- /dev/null +++ b/src/app/shared/charts/index.ts @@ -0,0 +1,2 @@ +export { ChartComponent } from './chart'; +export { BRAND, BRAND_SOFT, DANGER, MUTED, baseOptions, formatMoney } from './chart-options';