diff --git a/docs-website/public/plugin-images/metrics.png b/docs-website/public/plugin-images/metrics.png new file mode 100644 index 00000000..134d6ac7 Binary files /dev/null and b/docs-website/public/plugin-images/metrics.png differ diff --git a/packages/vendure-plugin-metrics/CHANGELOG.md b/packages/vendure-plugin-metrics/CHANGELOG.md index 53fb8f59..89f5a289 100644 --- a/packages/vendure-plugin-metrics/CHANGELOG.md +++ b/packages/vendure-plugin-metrics/CHANGELOG.md @@ -1,3 +1,9 @@ +# 1.6.0 (2024-11-19) + +- Allow showing metrics of past X months instead of always past 12 months. +- Removed conversion metric, as it is can not be accurately calculated based on created orders alone +- Small improvement in query performance + # 1.5.0 (2024-11-03) - Allow specifying per metric if it's filterable by variant ID's diff --git a/packages/vendure-plugin-metrics/README.md b/packages/vendure-plugin-metrics/README.md index c02e0c18..3cca3fc7 100644 --- a/packages/vendure-plugin-metrics/README.md +++ b/packages/vendure-plugin-metrics/README.md @@ -2,25 +2,22 @@ ### [Official documentation here](https://pinelab-plugins.com/plugin/vendure-plugin-metrics) -A plugin to measure and visualize your shop's average order value (AOV),number of orders per -month or per week and number of items per product variant for the past 12 months (or weeks) per variants. +A plugin to visualize your shops most important metrics of the past year. -![image](https://user-images.githubusercontent.com/6604455/236404288-e55c37ba-9508-43e6-a54c-2eb7b3cd36ee.png) +![image](https://raw.githubusercontent.com/Pinelab-studio/pinelab-vendure-plugins/96ed9d15e7a2908e0620a8a1e92b1d8c9fe381a4/docs-website/public/plugin-images/metrics.png) ## Getting started 1. Configure the plugin in `vendure-config.ts`: ```ts -import { MetricsPlugin, AverageOrderValueMetric, SalesPerProductMetric } from "@pinelab/vendure-plugin-metrics"; +import { MetricsPlugin } from "@pinelab/vendure-plugin-metrics"; plugins: [ ... MetricsPlugin.init({ - metrics: [ - new AverageOrderValueMetric(), - new SalesPerProductMetric() - ] + // Consider displaying fewer months for shops with a lot of orders + displayPastMonths: 13 }), AdminUiPlugin.init({ port: 3002, @@ -34,15 +31,17 @@ plugins: [ ] ``` -2. Start your Vendure server and login as administrator -3. You should now be able to add the widget `metrics` on your dashboard. +2. Rebuild your Admin UI +3. Start your Vendure server and login as administrator +4. You should now be able to add the widget `metrics` on your dashboard. Metric results are cached in memory to prevent heavy database queries every time a user opens its dashboard. -### Default built-in Metrics +### Built-in Metrics -1. Average Order Value (AOV): The average of `order.totalWithTax` of the orders per week/month -2. Sales per product: The number of items sold. When no variants are selected, this metric counts the total nr of items per order. +1. Revenue (per product): The total revenue per month, or the revenue generated by specific variants if a variant is selected. +2. Average Order Value (AOV): The average of `order.totalWithTax` of the orders per week/month +3. Units sold: The number of units sold for the selected variant(s). # Custom Metrics @@ -67,6 +66,10 @@ import { export class AverageOrderLineValue implements MetricStrategy { readonly metricType: AdvancedMetricType = AdvancedMetricType.Currency; readonly code = 'average-orderline-value'; + /** + * Determines if this metric allows filtering by variants + */ + readonly allowProductSelection = true; getTitle(ctx: RequestContext): string { return `Average Order Line Value`; diff --git a/packages/vendure-plugin-metrics/package.json b/packages/vendure-plugin-metrics/package.json index c9b4abde..1946f47d 100644 --- a/packages/vendure-plugin-metrics/package.json +++ b/packages/vendure-plugin-metrics/package.json @@ -1,6 +1,6 @@ { "name": "@pinelab/vendure-plugin-metrics", - "version": "1.5.0", + "version": "1.6.0", "description": "Vendure plugin measuring and visualizing e-commerce metrics", "author": "Martijn van de Brug ", "homepage": "https://pinelab-plugins.com/", diff --git a/packages/vendure-plugin-metrics/src/api/metrics.service.ts b/packages/vendure-plugin-metrics/src/api/metrics.service.ts index c14a0d77..c0bd4d5f 100644 --- a/packages/vendure-plugin-metrics/src/api/metrics.service.ts +++ b/packages/vendure-plugin-metrics/src/api/metrics.service.ts @@ -48,16 +48,18 @@ export class MetricsService { ); const today = endOfDay(new Date()); // Use start of month, because we'd like to see the full results of last years same month - const oneYearAgo = startOfMonth(sub(today, { years: 1 })); + const startDate = startOfMonth( + sub(today, { months: this.pluginOptions.displayPastMonths }) + ); // For each metric strategy return Promise.all( this.metricStrategies.map(async (metricStrategy) => { const cacheKeyObject = { code: metricStrategy.code, - from: today.toDateString(), - to: oneYearAgo.toDateString(), + from: startDate.toDateString(), + to: today.toDateString(), channel: ctx.channel.token, - variantIds: [] as string[], + variantIds: [] as string[], // Set below if input is given }; if (metricStrategy.allowProductSelection) { // Only use variantIds for cache key if the strategy allows filtering by variants @@ -78,14 +80,14 @@ export class MetricsService { const allEntities = await metricStrategy.loadEntities( ctx, new Injector(this.moduleRef), - oneYearAgo, + startDate, today, variants ); const entitiesPerMonth = this.splitEntitiesInMonths( metricStrategy, allEntities, - oneYearAgo, + startDate, today ); // Calculate datapoints per 'name', because we could be dealing with a multi line chart diff --git a/packages/vendure-plugin-metrics/src/api/metrics/conversion.ts b/packages/vendure-plugin-metrics/src/api/metrics/conversion.ts deleted file mode 100644 index d7689676..00000000 --- a/packages/vendure-plugin-metrics/src/api/metrics/conversion.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { - Injector, - Logger, - Order, - RequestContext, - TransactionalConnection, -} from '@vendure/core'; -import { loggerCtx } from '../../constants'; -import { AdvancedMetricType } from '../../ui/generated/graphql'; -import { MetricStrategy, NamedDatapoint } from '../metric-strategy'; - -// Minimal order with only the fields needed for the metric -type OrderWithDates = Pick; - -/** - * Calculates the conversion per month - */ -export class ConversionMetric implements MetricStrategy { - readonly metricType: AdvancedMetricType = AdvancedMetricType.Number; - readonly code = 'conversion'; - readonly allowProductSelection = false; - - getTitle(ctx: RequestContext): string { - return `Conversion`; - } - - getSortableField(entity: Order): Date { - return entity.orderPlacedAt ?? entity.updatedAt; - } - - async loadEntities( - ctx: RequestContext, - injector: Injector, - from: Date, - to: Date - ): Promise { - let skip = 0; - const take = 5000; - let hasMoreOrders = true; - const orders: Order[] = []; - const fromDate = from.toISOString(); - const toDate = to.toISOString(); - while (hasMoreOrders) { - let query = injector - .get(TransactionalConnection) - .getRepository(ctx, Order) - .createQueryBuilder('order') - .select(['order.orderPlacedAt', 'order.updatedAt']) - .leftJoin('order.channels', 'orderChannel') - .where(`orderChannel.id=:channelId`, { channelId: ctx.channelId }) - .andWhere('order.orderPlacedAt BETWEEN :fromDate AND :toDate', { - fromDate, - toDate, - }) - .orWhere( - 'order.orderPlacedAt IS NULL AND order.updatedAt BETWEEN :fromDate AND :toDate', - { fromDate, toDate } - ) - .offset(skip) - .limit(take); - const [items, totalOrders] = await query.getManyAndCount(); - orders.push(...items); - Logger.debug( - `Fetched orders ${skip}-${skip + take} for metric ${ - this.code - } for channel${ctx.channel.token}`, - loggerCtx - ); - skip += items.length; - if (orders.length >= totalOrders) { - hasMoreOrders = false; - } - } - return orders; - } - - calculateDataPoints( - ctx: RequestContext, - entities: OrderWithDates[] - ): NamedDatapoint[] { - let legendLabel = 'Conversion % of created orders to placed orders'; - if (!entities.length) { - // Return 0% - return [ - { - legendLabel, - value: 0, - }, - ]; - } - const totalOrders = entities.length; - const placedOrders = entities.filter((o) => o.orderPlacedAt).length; - const placedPercentage = (placedOrders / totalOrders) * 100; - return [ - { - legendLabel, - value: Math.round(placedPercentage * 100) / 100, - }, - ]; - } -} diff --git a/packages/vendure-plugin-metrics/src/api/metrics/units-sold-metric.ts b/packages/vendure-plugin-metrics/src/api/metrics/units-sold-metric.ts index 16de6413..83679adc 100644 --- a/packages/vendure-plugin-metrics/src/api/metrics/units-sold-metric.ts +++ b/packages/vendure-plugin-metrics/src/api/metrics/units-sold-metric.ts @@ -44,7 +44,12 @@ export class UnitsSoldMetric implements MetricStrategy { .getRepository(ctx, OrderLine) .createQueryBuilder('orderLine') .leftJoin('orderLine.order', 'order') - .addSelect(['order.id', 'order.orderPlacedAt']) + .select([ + 'order.id', + 'order.orderPlacedAt', + 'orderLine.id', + 'orderLine.quantity', + ]) .leftJoin('order.channels', 'channel') .where(`channel.id=:channelId`, { channelId: ctx.channelId }) .andWhere('order.orderPlacedAt BETWEEN :fromDate AND :toDate', { diff --git a/packages/vendure-plugin-metrics/src/index.ts b/packages/vendure-plugin-metrics/src/index.ts index be1e214b..34eeeff7 100644 --- a/packages/vendure-plugin-metrics/src/index.ts +++ b/packages/vendure-plugin-metrics/src/index.ts @@ -5,4 +5,3 @@ export * from './ui/generated/graphql'; export * from './api/metrics/average-order-value'; export * from './api/metrics/revenue-per-product'; export * from './api/metrics/units-sold-metric'; -export * from './api/metrics/conversion'; diff --git a/packages/vendure-plugin-metrics/src/metrics.plugin.ts b/packages/vendure-plugin-metrics/src/metrics.plugin.ts index 5090e495..d618e764 100644 --- a/packages/vendure-plugin-metrics/src/metrics.plugin.ts +++ b/packages/vendure-plugin-metrics/src/metrics.plugin.ts @@ -9,10 +9,17 @@ import { PLUGIN_INIT_OPTIONS } from './constants'; import { RevenuePerProduct } from './api/metrics/revenue-per-product'; import { AverageOrderValueMetric } from './api/metrics/average-order-value'; import { UnitsSoldMetric } from './api/metrics/units-sold-metric'; -import { ConversionMetric } from './api/metrics/conversion'; export interface MetricsPluginOptions { + /** + * The enabled metrics shown in the widget. + */ metrics: MetricStrategy[]; + /** + * The number of past months to display in the metrics widget. + * If your shop has a lot of orders, consider using only the last 3 months for example. + */ + displayPastMonths: number; } @VendurePlugin({ @@ -31,14 +38,17 @@ export class MetricsPlugin { static options: MetricsPluginOptions = { metrics: [ new RevenuePerProduct(), - new ConversionMetric(), new AverageOrderValueMetric(), new UnitsSoldMetric(), ], + displayPastMonths: 13, }; - static init(options: MetricsPluginOptions): typeof MetricsPlugin { - this.options = options; + static init(options: Partial): typeof MetricsPlugin { + this.options = { + ...this.options, + ...options, + }; return MetricsPlugin; } diff --git a/packages/vendure-plugin-metrics/test/dev-server.ts b/packages/vendure-plugin-metrics/test/dev-server.ts index e3524c27..700ca39c 100644 --- a/packages/vendure-plugin-metrics/test/dev-server.ts +++ b/packages/vendure-plugin-metrics/test/dev-server.ts @@ -31,7 +31,9 @@ import { addItem, createSettledOrder } from '../../test/src/shop-utils'; paymentMethodHandlers: [testPaymentMethod], }, plugins: [ - MetricsPlugin, + MetricsPlugin.init({ + displayPastMonths: 19, + }), DefaultSearchPlugin, AdminUiPlugin.init({ port: 3002, diff --git a/packages/vendure-plugin-metrics/test/metrics.spec.ts b/packages/vendure-plugin-metrics/test/metrics.spec.ts index d01c7341..55d74675 100644 --- a/packages/vendure-plugin-metrics/test/metrics.spec.ts +++ b/packages/vendure-plugin-metrics/test/metrics.spec.ts @@ -71,7 +71,7 @@ describe('Metrics', () => { await expect(promise).rejects.toThrow('authorized'); }); - it('Fetches metrics for past 13 months', async () => { + it('Fetches metrics for past 14 months', async () => { await adminClient.asSuperAdmin(); const { advancedMetricSummaries } = await adminClient.query(GET_METRICS); @@ -84,23 +84,17 @@ describe('Metrics', () => { const salesPerProduct = advancedMetricSummaries.find( (m) => m.code === 'units-sold' )!; - const conversion = advancedMetricSummaries.find( - (m) => m.code === 'conversion' - )!; - expect(advancedMetricSummaries.length).toEqual(4); - expect(averageOrderValue.series[0].values.length).toEqual(13); - expect(averageOrderValue.labels.length).toEqual(13); + expect(advancedMetricSummaries.length).toEqual(3); + expect(averageOrderValue.series[0].values.length).toEqual(14); + expect(averageOrderValue.labels.length).toEqual(14); // All orders are 4102 without tax, so that the AOV - expect(averageOrderValue.series[0].values[12]).toEqual(4102); - expect(revenuePerProduct.series[0].values.length).toEqual(13); - expect(revenuePerProduct.labels.length).toEqual(13); + expect(averageOrderValue.series[0].values[13]).toEqual(4102); + expect(revenuePerProduct.series[0].values.length).toEqual(14); + expect(revenuePerProduct.labels.length).toEqual(14); // All orders are 4102 without tax, and we placed 3 orders - expect(revenuePerProduct.series[0].values[12]).toEqual(3 * 4102); //12306 - expect(salesPerProduct.series[0].values.length).toEqual(13); - expect(salesPerProduct.labels.length).toEqual(13); - expect(conversion.series[0].values.length).toEqual(13); - expect(conversion.labels.length).toEqual(13); - expect(conversion.series[0].values[12]).toEqual(100); + expect(revenuePerProduct.series[0].values[13]).toEqual(3 * 4102); //12306 + expect(salesPerProduct.series[0].values.length).toEqual(14); + expect(salesPerProduct.labels.length).toEqual(14); }); it('Fetches metrics for specific variant', async () => { @@ -109,29 +103,29 @@ describe('Metrics', () => { await adminClient.query(GET_METRICS, { input: { variantIds: [1, 2] }, }); - expect(advancedMetricSummaries.length).toEqual(4); + expect(advancedMetricSummaries.length).toEqual(3); const revenuePerProduct = advancedMetricSummaries.find( (m) => m.code === 'revenue-per-product' )!; // For revenue per product we expect 2 series: one for each variant - expect(revenuePerProduct.series[0].values.length).toEqual(13); - expect(revenuePerProduct.series[1].values.length).toEqual(13); - expect(revenuePerProduct.labels.length).toEqual(13); + expect(revenuePerProduct.series[0].values.length).toEqual(14); + expect(revenuePerProduct.series[1].values.length).toEqual(14); + expect(revenuePerProduct.labels.length).toEqual(14); // Expect the first series (variant 1), to have 389700 revenue in last month - expect(revenuePerProduct.series[0].values[12]).toEqual(3897); + expect(revenuePerProduct.series[0].values[13]).toEqual(3897); // Expect the first series (variant 2), to have 839400 revenue in last month - expect(revenuePerProduct.series[1].values[12]).toEqual(8394); + expect(revenuePerProduct.series[1].values[13]).toEqual(8394); const salesPerProduct = advancedMetricSummaries.find( (m) => m.code === 'units-sold' )!; // For sales per product we expect 2 series: one for each variant - expect(salesPerProduct.series[0].values.length).toEqual(13); - expect(salesPerProduct.series[1].values.length).toEqual(13); - expect(salesPerProduct.labels.length).toEqual(13); + expect(salesPerProduct.series[0].values.length).toEqual(14); + expect(salesPerProduct.series[1].values.length).toEqual(14); + expect(salesPerProduct.labels.length).toEqual(14); // Expect the first series (variant 1), to have 3 revenue in last month - expect(salesPerProduct.series[0].values[12]).toEqual(3); + expect(salesPerProduct.series[0].values[13]).toEqual(3); // Expect the first series (variant 2), to have 6 revenue in last month - expect(salesPerProduct.series[1].values[12]).toEqual(6); + expect(salesPerProduct.series[1].values[13]).toEqual(6); }); if (process.env.TEST_ADMIN_UI) {