Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"build:lib": "vite build --config vite.lib.config.mts",
"build": "npm run check-types && npm run build:vite && npm run build:plugin && npm run build:lib",
"watch": "tsc --project tsconfig.vite.json --watch",
"test": "vitest run",
"test": "vitest run && vitest --config vitest.plugin.config.mts --run",
"audit:bundle": "cross-env RUN_BUNDLE_AUDIT=true vitest run vite/tests/bundle-singleton.spec.ts vite/tests/bundle-singleton-dev-serving.spec.ts",
"lint": "eslint .",
"preview": "vite preview",
Expand Down
14 changes: 3 additions & 11 deletions packages/dashboard/plugin/config/metrics-strategies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,9 @@ export class AverageOrderValueMetric implements MetricCalculation {

calculateEntry(ctx: RequestContext, data: MetricData): DashboardMetricSummaryEntry {
const label = data.date.toISOString();
if (!data.orders.length) {
return {
label,
value: 0,
};
}
const total = data.orders.map(o => o.totalWithTax).reduce((_total, current) => _total + current, 0);
const average = Math.round(total / data.orders.length);
return {
label,
value: average,
value: data.averageOrderValue,
};
}
}
Expand All @@ -63,7 +55,7 @@ export class OrderCountMetric implements MetricCalculation {
const label = data.date.toISOString();
return {
label,
value: data.orders.length,
value: data.orderCount,
};
}
}
Expand All @@ -82,7 +74,7 @@ export class OrderTotalMetric implements MetricCalculation {
const label = data.date.toISOString();
return {
label,
value: data.orders.map(o => o.totalWithTax).reduce((_total, current) => _total + current, 0),
value: data.orderTotal,
};
}
}
112 changes: 112 additions & 0 deletions packages/dashboard/plugin/service/metrics.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { CacheService, RequestContext, TransactionalConnection } from '@vendure/core';
import { startOfDay } from 'date-fns';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { DashboardMetricType } from '../types.js';
import { MetricsService } from './metrics.service.js';

describe('Dashboard MetricsService', () => {
const queryBuilder = {
select: vi.fn(),
addSelect: vi.fn(),
innerJoin: vi.fn(),
where: vi.fn(),
andWhere: vi.fn(),
groupBy: vi.fn(),
orderBy: vi.fn(),
getRawMany: vi.fn(),
};
const repository = {
createQueryBuilder: vi.fn(() => queryBuilder),
};
const connection = {
getRepository: vi.fn(() => repository),
};
const cacheService = {
get: vi.fn(),
set: vi.fn(),
};
const ctx = {
channelId: 1,
channel: { token: 'default-channel' },
} as RequestContext;

beforeEach(() => {
vi.clearAllMocks();
for (const method of [
queryBuilder.select,
queryBuilder.addSelect,
queryBuilder.innerJoin,
queryBuilder.where,
queryBuilder.andWhere,
queryBuilder.groupBy,
queryBuilder.orderBy,
]) {
method.mockReturnValue(queryBuilder);
}
cacheService.get.mockResolvedValue(undefined);
cacheService.set.mockResolvedValue(undefined);
});

it('calculates all metrics from database aggregates containing more than 1000 orders', async () => {
const date = new Date('2026-07-15T12:00:00.000Z');
const dateKey = startOfDay(date).toISOString().split('T')[0];
queryBuilder.getRawMany.mockResolvedValue([
{
date: dateKey,
orderCount: '1501',
orderTotal: '3002000',
averageOrderValue: '2000',
},
]);
const service = new MetricsService(
connection as unknown as TransactionalConnection,
cacheService as unknown as CacheService,
);

const result = await service.getMetrics(ctx, {
startDate: date.toISOString(),
endDate: date.toISOString(),
refresh: true,
types: [
DashboardMetricType.OrderCount,
DashboardMetricType.OrderTotal,
DashboardMetricType.AverageOrderValue,
],
});

expect(result.find(metric => metric.type === DashboardMetricType.OrderCount)?.entries[0].value).toBe(
1501,
);
expect(result.find(metric => metric.type === DashboardMetricType.OrderTotal)?.entries[0].value).toBe(
3002000,
);
expect(
result.find(metric => metric.type === DashboardMetricType.AverageOrderValue)?.entries[0].value,
).toBe(2000);
expect(queryBuilder.select).toHaveBeenCalledWith('DATE(order.orderPlacedAt)', 'date');
expect(queryBuilder.addSelect).toHaveBeenCalledWith(
'COALESCE(ROUND(AVG(order.subTotalWithTax + order.shippingWithTax)), 0)',
'averageOrderValue',
);
expect(queryBuilder.groupBy).toHaveBeenCalledWith('DATE(order.orderPlacedAt)');
expect(queryBuilder.getRawMany).toHaveBeenCalledOnce();
});

it('fills days without orders with zero values', async () => {
queryBuilder.getRawMany.mockResolvedValue([]);
const service = new MetricsService(
connection as unknown as TransactionalConnection,
cacheService as unknown as CacheService,
);

const result = await service.getMetrics(ctx, {
startDate: '2026-07-15T12:00:00.000Z',
endDate: '2026-07-15T12:00:00.000Z',
refresh: true,
types: [DashboardMetricType.OrderCount, DashboardMetricType.OrderTotal],
});

expect(result.map(metric => metric.entries[0].value)).toEqual([0, 0]);
});
});
83 changes: 43 additions & 40 deletions packages/dashboard/plugin/service/metrics.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,16 @@ import {

export type MetricData = {
date: Date;
orders: Order[];
orderCount: number;
orderTotal: number;
averageOrderValue: number;
};

type RawMetricData = {
date: string;
orderCount: string | number;
orderTotal: string | number;
averageOrderValue: string | number;
};

@Injectable()
Expand Down Expand Up @@ -96,60 +105,54 @@ export class MetricsService {
const diffTime = Math.abs(endDate.getTime() - startDate.getTime());
const nrOfDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;

// Get orders in a loop until we have all
let skip = 0;
const take = 1000;
let hasMoreOrders = true;
const orders: Order[] = [];
while (hasMoreOrders) {
const query = orderRepo
.createQueryBuilder('order')
.leftJoin('order.channels', 'orderChannel')
.where('orderChannel.id=:channelId', { channelId: ctx.channelId })
.andWhere('order.orderPlacedAt >= :startDate', {
startDate: startDate.toISOString(),
})
.andWhere('order.orderPlacedAt <= :endDate', {
endDate: endDate.toISOString(),
})
.skip(skip)
.take(take);
const [items, nrOfOrders] = await query.getManyAndCount();
orders.push(...items);
Logger.verbose(
`Fetched orders ${skip}-${skip + take} for channel ${
ctx.channel.token
} for date range metrics`,
loggerCtx,
);
skip += items.length;
if (orders.length >= nrOfOrders) {
hasMoreOrders = false;
}
}
const dateExpression = 'DATE(order.orderPlacedAt)';
const rows: RawMetricData[] = await orderRepo
.createQueryBuilder('order')
.select(dateExpression, 'date')
.addSelect('COUNT(order.id)', 'orderCount')
.addSelect('COALESCE(SUM(order.subTotalWithTax + order.shippingWithTax), 0)', 'orderTotal')
.addSelect(
'COALESCE(ROUND(AVG(order.subTotalWithTax + order.shippingWithTax)), 0)',
'averageOrderValue',
)
.innerJoin('order.channels', 'orderChannel')
.where('orderChannel.id = :channelId', { channelId: ctx.channelId })
.andWhere('order.orderPlacedAt >= :startDate', { startDate: startDate.toISOString() })
.andWhere('order.orderPlacedAt <= :endDate', { endDate: endDate.toISOString() })
.groupBy(dateExpression)
.orderBy(dateExpression, 'ASC')
.getRawMany();

Logger.verbose(
`Finished fetching all ${orders.length} orders for channel ${ctx.channel.token} for date range metrics`,
`Finished aggregating order metrics for channel ${ctx.channel.token} for ${rows.length} days`,
loggerCtx,
);

const dataPerDay = new Map<string, MetricData>();
const metricsByDate = new Map(
rows.map(row => [
row.date,
{
orderCount: Number(row.orderCount),
orderTotal: Number(row.orderTotal),
averageOrderValue: Number(row.averageOrderValue),
},
]),
);

// Create a map entry for each day in the range
for (let i = 0; i < nrOfDays; i++) {
const currentDate = new Date(startDate);
currentDate.setDate(startDate.getDate() + i);
const dateKey = currentDate.toISOString().split('T')[0]; // YYYY-MM-DD format

// Filter orders for this specific day
const ordersForDay = orders.filter(order => {
if (!order.orderPlacedAt) return false;
const orderDate = new Date(order.orderPlacedAt).toISOString().split('T')[0];
return orderDate === dateKey;
});
const data = metricsByDate.get(dateKey);

dataPerDay.set(dateKey, {
orders: ordersForDay,
date: currentDate,
orderCount: data?.orderCount ?? 0,
orderTotal: data?.orderTotal ?? 0,
averageOrderValue: data?.averageOrderValue ?? 0,
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AnimatedCurrency, AnimatedNumber } from '@/vdb/components/shared/animated-number.js';
import { api } from '@/vdb/graphql/api.js';
import { ResultOf } from '@/vdb/graphql/graphql.js';
import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react/macro';
import { useQuery } from '@tanstack/react-query';
Expand All @@ -11,11 +12,21 @@ import { useWidgetFilters } from '@/vdb/hooks/use-widget-filters.js';
import { orderSummaryQuery } from './order-summary-widget.graphql.js';

const WIDGET_ID = 'orders-summary-widget';
const ORDER_COUNT_METRIC = 'OrderCount';
const ORDER_TOTAL_METRIC = 'OrderTotal';

interface PercentageChangeProps {
value: number;
}

function getMetricTotal(
data: ResultOf<typeof orderSummaryQuery> | undefined,
type: typeof ORDER_COUNT_METRIC | typeof ORDER_TOTAL_METRIC,
): number {
const entries = data?.dashboardMetricSummary.find(metric => metric.type === type)?.entries;
return entries?.reduce((total, entry) => total + entry.value, 0) ?? 0;
}

function PercentageChange({ value }: PercentageChangeProps) {
const isZero = value === 0;
const isPositive = value > 0;
Expand Down Expand Up @@ -53,6 +64,7 @@ export function OrdersSummaryWidget() {
api.query(orderSummaryQuery, {
start: variables.start,
end: variables.end,
refresh: true,
}),
});

Expand All @@ -62,6 +74,7 @@ export function OrdersSummaryWidget() {
api.query(orderSummaryQuery, {
start: variables.previousStart,
end: variables.previousEnd,
refresh: true,
}),
});

Expand All @@ -70,11 +83,10 @@ export function OrdersSummaryWidget() {
return ((current - previous) / previous) * 100;
};

const currentTotalOrders = data?.orders.totalItems ?? 0;
const previousTotalOrders = previousData?.orders.totalItems ?? 0;
const currentRevenue = data?.orders.items.reduce((acc, order) => acc + order.totalWithTax, 0) ?? 0;
const previousRevenue =
previousData?.orders.items.reduce((acc, order) => acc + order.totalWithTax, 0) ?? 0;
const currentTotalOrders = getMetricTotal(data, ORDER_COUNT_METRIC);
const previousTotalOrders = getMetricTotal(previousData, ORDER_COUNT_METRIC);
const currentRevenue = getMetricTotal(data, ORDER_TOTAL_METRIC);
const previousRevenue = getMetricTotal(previousData, ORDER_TOTAL_METRIC);

const orderChange = calculatePercentChange(currentTotalOrders, previousTotalOrders);
const revenueChange = calculatePercentChange(currentRevenue, previousRevenue);
Expand Down Expand Up @@ -111,4 +123,4 @@ export function OrdersSummaryWidget() {
</div>
</DashboardBaseWidget>
);
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
import { graphql } from '@/vdb/graphql/graphql.js';

export const orderSummaryQuery = graphql(`
query GetOrderSummary($start: DateTime!, $end: DateTime!) {
orders(options: { filter: { orderPlacedAt: { between: { start: $start, end: $end } } } }) {
totalItems
items {
id
totalWithTax
currencyCode
query GetOrderSummaryMetrics($start: DateTime!, $end: DateTime!, $refresh: Boolean) {
dashboardMetricSummary(
input: {
types: [OrderCount, OrderTotal]
startDate: $start
endDate: $end
refresh: $refresh
}
) {
type
entries {
value
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions packages/dashboard/tsconfig.plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,8 @@
},
"include": [
"plugin/**/*"
],
"exclude": [
"plugin/**/*.spec.ts"
]
}
2 changes: 1 addition & 1 deletion packages/dashboard/vite.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export default ({ mode }: { mode: string }) => {
...sharedTestConfig,
globals: true,
environment: 'jsdom',
exclude: ['./e2e/**/*', './plugin/**/*', '**/node_modules/**/*'],
exclude: ['./dist/**/*', './e2e/**/*', './plugin/**/*', '**/node_modules/**/*'],
environmentMatchGlobs: [
['vite/tests/**', 'node'],
],
Expand Down
13 changes: 13 additions & 0 deletions packages/dashboard/vitest.plugin.config.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import swc from 'unplugin-swc';
import { defineConfig } from 'vitest/config';

import { sharedTestConfig } from '../../vitest.shared.mjs';

export default defineConfig({
test: {
...sharedTestConfig,
environment: 'node',
include: ['plugin/**/*.spec.ts'],
},
plugins: [swc.vite()],
});
Loading