From 880579bef5d494bc48f79674f80137b7a8becfb1 Mon Sep 17 00:00:00 2001 From: PhilippTheServer Date: Wed, 26 Aug 2026 15:33:51 +0200 Subject: [PATCH] feat(analytics): analytics page, and dashboard money from the API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard summed revenue and average order value client-side from the last 100 orders. Its own comment admitted it. The headline numbers therefore stopped being the shop's numbers at order 101, and there was no trend, no product view and no sight of where orders stop converting. Adds an Analytics page — revenue over time, orders and units, the order funnel, per-SKU performance and active-but-unsold stock — reading /v1/admin/analytics/*, which computes everything in SQL over the whole order history. The dashboard's money tiles now come from the same summary endpoint over a 30-day window. Its work queues still read the orders list, which is the right source for them: "what needs doing now" is exactly the newest orders. Money is shown per currency and never summed across them. The API returns a list keyed by currency because a cross-currency total is meaningless; adding them up here would put the bug straight back. Charts use Chart.js directly behind a thin standalone wrapper rather than an Angular wrapper library. The app is zoneless and tracks the newest Angular, and a wrapper's peer range would gate every future Angular upgrade — ng2-charts would also pull in @angular/cdk, which this app does not otherwise need. Only the controllers actually drawn are registered, so the rest of Chart.js is tree-shaken, and the whole library sits in a lazy chunk: nothing but /analytics pays for it. isoDate and rangeEndingToday live in core/date-range rather than beside the page, because the dashboard needs them and importing from a page component would drag Chart.js into a screen that draws no charts. Percentage change from a zero baseline renders as "no prior data", not 0% and not an arrow. It is undefined, and a direction would assert what the data cannot support. Closes #7 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YY1ekLLeFLkAU2kvdQ8Ey4 --- angular.json | 3 +- package-lock.json | 19 + package.json | 1 + src/app/app.routes.ts | 6 + src/app/core/api/analytics.service.spec.ts | 66 ++++ src/app/core/api/analytics.service.ts | 55 +++ src/app/core/date-range.ts | 33 ++ src/app/core/models/analytics.models.ts | 144 ++++++++ .../features/analytics/analytics.page.html | 328 ++++++++++++++++++ .../features/analytics/analytics.page.spec.ts | 208 +++++++++++ src/app/features/analytics/analytics.page.ts | 258 ++++++++++++++ .../features/dashboard/dashboard.page.html | 12 +- .../features/dashboard/dashboard.page.spec.ts | 118 +++++++ src/app/features/dashboard/dashboard.page.ts | 66 +++- src/app/layout/nav.model.spec.ts | 3 +- src/app/layout/nav.model.ts | 6 + src/app/shared/charts/chart-options.ts | 67 ++++ src/app/shared/charts/chart.ts | 85 +++++ src/app/shared/charts/index.ts | 2 + 19 files changed, 1457 insertions(+), 23 deletions(-) create mode 100644 src/app/core/api/analytics.service.spec.ts create mode 100644 src/app/core/api/analytics.service.ts create mode 100644 src/app/core/date-range.ts create mode 100644 src/app/core/models/analytics.models.ts create mode 100644 src/app/features/analytics/analytics.page.html create mode 100644 src/app/features/analytics/analytics.page.spec.ts create mode 100644 src/app/features/analytics/analytics.page.ts create mode 100644 src/app/features/dashboard/dashboard.page.spec.ts create mode 100644 src/app/shared/charts/chart-options.ts create mode 100644 src/app/shared/charts/chart.ts create mode 100644 src/app/shared/charts/index.ts 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';