feat(dashboard): compose live summary data across features
Validate Docs / validate-docs (push) Successful in 59s Details

This commit is contained in:
Mike Kell 2026-04-04 14:07:21 -04:00
parent 3330ed23b3
commit 00a667d19e
7 changed files with 849 additions and 26 deletions

View File

@ -0,0 +1,34 @@
import 'package:flutter/foundation.dart';
import '../domain/dashboard_summary.dart';
import 'get_dashboard_summary.dart';
/// Controller that manages the dashboard summary state.
///
/// Follows the same [ChangeNotifier] pattern used by other feature
/// controllers (e.g. `OrdersController`).
class DashboardController extends ChangeNotifier {
final GetDashboardSummary _getDashboardSummary;
DashboardController(this._getDashboardSummary);
bool isLoading = false;
DashboardSummary summary = DashboardSummary.empty;
Object? error;
/// Loads the aggregated dashboard summary from all repositories.
Future<void> load() async {
isLoading = true;
error = null;
notifyListeners();
try {
summary = await _getDashboardSummary();
} catch (e) {
error = e;
} finally {
isLoading = false;
notifyListeners();
}
}
}

View File

@ -0,0 +1,36 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import '../domain/dashboard_summary.dart';
/// Use case: fetches data from all three repositories and returns an
/// aggregated [DashboardSummary].
///
/// This lives in the app layer (not in a feature package) because it
/// crosses feature boundaries.
class GetDashboardSummary {
final InventoryRepository inventoryRepository;
final ProductPublishingRepository productPublishingRepository;
final OrdersRepository ordersRepository;
GetDashboardSummary({
required this.inventoryRepository,
required this.productPublishingRepository,
required this.ordersRepository,
});
Future<DashboardSummary> call() async {
final results = await Future.wait([
inventoryRepository.getInventoryItems(),
productPublishingRepository.getProductDrafts(),
ordersRepository.getOrders(),
]);
return DashboardSummary.fromData(
inventoryItems: results[0] as List<InventoryItem>,
productDrafts: results[1] as List<ProductDraft>,
orders: results[2] as List<Order>,
);
}
}

View File

@ -0,0 +1,148 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
/// Aggregated summary data displayed on the dashboard.
///
/// This is an app-level value object that composes data from multiple
/// feature-package repositories without leaking domain logic back into
/// those packages.
class DashboardSummary {
/// Total number of inventory items.
final int totalProducts;
/// Items with [InventoryStatus.inStock].
final int inStock;
/// Items with [InventoryStatus.lowStock].
final int lowStock;
/// Items with [InventoryStatus.outOfStock].
final int outOfStock;
/// Product drafts with [PublishStatus.draft].
final int draftProducts;
/// Total number of orders.
final int totalOrders;
/// Orders with [OrderStatus.pending].
final int pendingOrders;
/// Orders with [OrderStatus.processing] or [OrderStatus.shipped].
final int activeOrders;
/// Revenue from delivered orders.
final double deliveredRevenue;
const DashboardSummary({
required this.totalProducts,
required this.inStock,
required this.lowStock,
required this.outOfStock,
required this.draftProducts,
required this.totalOrders,
required this.pendingOrders,
required this.activeOrders,
required this.deliveredRevenue,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is DashboardSummary &&
totalProducts == other.totalProducts &&
inStock == other.inStock &&
lowStock == other.lowStock &&
outOfStock == other.outOfStock &&
draftProducts == other.draftProducts &&
totalOrders == other.totalOrders &&
pendingOrders == other.pendingOrders &&
activeOrders == other.activeOrders &&
deliveredRevenue == other.deliveredRevenue;
@override
int get hashCode => Object.hash(
totalProducts,
inStock,
lowStock,
outOfStock,
draftProducts,
totalOrders,
pendingOrders,
activeOrders,
deliveredRevenue,
);
/// An empty summary used as the initial / default state.
static const empty = DashboardSummary(
totalProducts: 0,
inStock: 0,
lowStock: 0,
outOfStock: 0,
draftProducts: 0,
totalOrders: 0,
pendingOrders: 0,
activeOrders: 0,
deliveredRevenue: 0,
);
/// Computes a [DashboardSummary] from raw repository data.
factory DashboardSummary.fromData({
required List<InventoryItem> inventoryItems,
required List<ProductDraft> productDrafts,
required List<Order> orders,
}) {
// Inventory counts
final totalProducts = inventoryItems.length;
var inStock = 0;
var lowStock = 0;
var outOfStock = 0;
for (final item in inventoryItems) {
switch (item.status) {
case InventoryStatus.inStock:
inStock++;
case InventoryStatus.lowStock:
lowStock++;
case InventoryStatus.outOfStock:
outOfStock++;
case InventoryStatus.draft:
break;
}
}
// Draft product count
final draftProducts = productDrafts.where((d) => d.status == PublishStatus.draft).length;
// Order counts
final totalOrders = orders.length;
var pendingOrders = 0;
var activeOrders = 0;
var deliveredRevenue = 0.0;
for (final order in orders) {
switch (order.status) {
case OrderStatus.pending:
pendingOrders++;
case OrderStatus.processing:
case OrderStatus.shipped:
activeOrders++;
case OrderStatus.delivered:
deliveredRevenue += order.total;
case OrderStatus.cancelled:
break;
}
}
return DashboardSummary(
totalProducts: totalProducts,
inStock: inStock,
lowStock: lowStock,
outOfStock: outOfStock,
draftProducts: draftProducts,
totalOrders: totalOrders,
pendingOrders: pendingOrders,
activeOrders: activeOrders,
deliveredRevenue: deliveredRevenue,
);
}
}

View File

@ -1,33 +1,75 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../dashboard/application/dashboard_controller.dart';
import '../dashboard/domain/dashboard_summary.dart';
import '../routing/app_routes.dart';
import '../shell/widgets/empty_state_panel.dart';
import '../shell/widgets/section_header.dart';
import '../shell/widgets/summary_card.dart';
/// The main dashboard page showing stub summary data.
/// The main dashboard page showing aggregated summary data.
///
/// Displays KPI cards (total products, in-stock, low-stock, draft) and a
/// quick-actions section. All data is hard-coded stub data until backend
/// integration is added.
class DashboardPage extends StatelessWidget {
const DashboardPage({super.key});
/// Displays KPI cards (total products, in-stock, low-stock, draft, orders,
/// pending, active, revenue) and a quick-actions section. Data is loaded
/// from the [DashboardController] which aggregates across repositories.
class DashboardPage extends StatefulWidget {
final DashboardController controller;
// Stub data
const DashboardPage({super.key, required this.controller});
static const int _totalProducts = 24;
static const int _inStock = 18;
static const int _lowStock = 4;
static const int _draft = 2;
@override
State<DashboardPage> createState() => _DashboardPageState();
}
class _DashboardPageState extends State<DashboardPage> {
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChanged);
// Kick off the initial load.
widget.controller.load();
}
@override
void didUpdateWidget(DashboardPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.controller != widget.controller) {
oldWidget.controller.removeListener(_onControllerChanged);
widget.controller.addListener(_onControllerChanged);
widget.controller.load();
}
}
@override
void dispose() {
widget.controller.removeListener(_onControllerChanged);
super.dispose();
}
void _onControllerChanged() => setState(() {});
@override
Widget build(BuildContext context) {
final controller = widget.controller;
if (controller.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (controller.error != null) {
return Center(
child: Text('Failed to load dashboard data.', style: Theme.of(context).textTheme.bodyLarge),
);
}
final summary = controller.summary;
return ListView(
children: [
const SectionHeader(title: 'Overview'),
const SizedBox(height: KcSpacing.sm),
_buildSummaryGrid(context),
_buildSummaryGrid(context, summary),
const SizedBox(height: KcSpacing.xl),
SectionHeader(
title: 'Quick Actions',
@ -52,7 +94,7 @@ class DashboardPage extends StatelessWidget {
// Summary cards grid
Widget _buildSummaryGrid(BuildContext context) {
Widget _buildSummaryGrid(BuildContext context, DashboardSummary summary) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
@ -65,29 +107,53 @@ class DashboardPage extends StatelessWidget {
}
final cards = [
const SummaryCard(
SummaryCard(
icon: Icons.inventory_2,
iconColor: KcColors.denimBlue,
label: 'Total Products',
value: '$_totalProducts',
value: '${summary.totalProducts}',
),
const SummaryCard(
SummaryCard(
icon: Icons.check_circle_outline,
iconColor: KcColors.success,
label: 'In Stock',
value: '$_inStock',
value: '${summary.inStock}',
),
const SummaryCard(
SummaryCard(
icon: Icons.warning_amber_rounded,
iconColor: KcColors.warning,
label: 'Low Stock',
value: '$_lowStock',
value: '${summary.lowStock}',
),
const SummaryCard(
SummaryCard(
icon: Icons.edit_note,
iconColor: KcColors.neutral,
label: 'Draft',
value: '$_draft',
value: '${summary.draftProducts}',
),
SummaryCard(
icon: Icons.receipt_long,
iconColor: KcColors.denimBlue,
label: 'Total Orders',
value: '${summary.totalOrders}',
),
SummaryCard(
icon: Icons.hourglass_empty,
iconColor: KcColors.warning,
label: 'Pending Orders',
value: '${summary.pendingOrders}',
),
SummaryCard(
icon: Icons.local_shipping_outlined,
iconColor: KcColors.success,
label: 'Active Orders',
value: '${summary.activeOrders}',
),
SummaryCard(
icon: Icons.attach_money,
iconColor: KcColors.success,
label: 'Revenue',
value: '\$${summary.deliveredRevenue.toStringAsFixed(2)}',
),
];

View File

@ -4,6 +4,8 @@ import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter/material.dart';
import '../composition/app_scope.dart';
import '../dashboard/application/dashboard_controller.dart';
import '../dashboard/application/get_dashboard_summary.dart';
import '../pages/dashboard_page.dart';
import '../pages/finance_placeholder_page.dart';
import '../pages/integrations_placeholder_page.dart';
@ -22,11 +24,21 @@ abstract final class AppRoutes {
static Route<dynamic> onGenerateRoute(RouteSettings settings) {
switch (settings.name) {
case dashboard:
return _buildRoute(
settings,
(context) =>
const AppShell(selectedRoute: dashboard, title: 'Dashboard', child: DashboardPage()),
return _buildRoute(settings, (context) {
final services = AppScope.of(context);
final controller = DashboardController(
GetDashboardSummary(
inventoryRepository: services.inventoryRepository,
productPublishingRepository: services.productPublishingRepository,
ordersRepository: services.ordersRepository,
),
);
return AppShell(
selectedRoute: dashboard,
title: 'Dashboard',
child: DashboardPage(controller: controller),
);
});
case inventory:
return _buildRoute(
settings,

View File

@ -0,0 +1,201 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/dashboard/application/dashboard_controller.dart';
import 'package:kell_web/dashboard/application/get_dashboard_summary.dart';
import 'package:kell_web/dashboard/domain/dashboard_summary.dart';
// Tiny stub repositories for testing
class _StubInventoryRepository implements InventoryRepository {
final List<InventoryItem> items;
_StubInventoryRepository([this.items = const []]);
@override
Future<List<InventoryItem>> getInventoryItems() async => items;
}
class _StubProductPublishingRepository implements ProductPublishingRepository {
final List<ProductDraft> drafts;
_StubProductPublishingRepository([this.drafts = const []]);
@override
Future<List<ProductDraft>> getProductDrafts() async => drafts;
@override
Future<ProductDraft> publishDraft(String id) => throw UnimplementedError();
}
class _StubOrdersRepository implements OrdersRepository {
final List<Order> orders;
_StubOrdersRepository([this.orders = const []]);
@override
Future<List<Order>> getOrders() async => orders;
}
class _FailingInventoryRepository implements InventoryRepository {
@override
Future<List<InventoryItem>> getInventoryItems() => throw Exception('network error');
}
// Tests
void main() {
group('DashboardController', () {
test('starts with empty summary and not loading', () {
final controller = DashboardController(
GetDashboardSummary(
inventoryRepository: _StubInventoryRepository(),
productPublishingRepository: _StubProductPublishingRepository(),
ordersRepository: _StubOrdersRepository(),
),
);
expect(controller.isLoading, false);
expect(controller.summary, DashboardSummary.empty);
expect(controller.error, isNull);
});
test('load() sets isLoading then populates summary', () async {
final inventoryItems = [
const InventoryItem(
id: '1',
sku: 'A',
name: 'A',
quantityOnHand: 10,
unitPrice: 5.0,
status: InventoryStatus.inStock,
),
const InventoryItem(
id: '2',
sku: 'B',
name: 'B',
quantityOnHand: 2,
unitPrice: 5.0,
status: InventoryStatus.lowStock,
),
];
final controller = DashboardController(
GetDashboardSummary(
inventoryRepository: _StubInventoryRepository(inventoryItems),
productPublishingRepository: _StubProductPublishingRepository(),
ordersRepository: _StubOrdersRepository(),
),
);
// Track notification sequence.
final loadingStates = <bool>[];
controller.addListener(() => loadingStates.add(controller.isLoading));
await controller.load();
// First notification: isLoading = true, second: isLoading = false.
expect(loadingStates, [true, false]);
expect(controller.isLoading, false);
expect(controller.error, isNull);
expect(controller.summary.totalProducts, 2);
expect(controller.summary.inStock, 1);
expect(controller.summary.lowStock, 1);
});
test('load() captures error and clears isLoading', () async {
final controller = DashboardController(
GetDashboardSummary(
inventoryRepository: _FailingInventoryRepository(),
productPublishingRepository: _StubProductPublishingRepository(),
ordersRepository: _StubOrdersRepository(),
),
);
await controller.load();
expect(controller.isLoading, false);
expect(controller.error, isNotNull);
// Summary should remain empty on error.
expect(controller.summary, DashboardSummary.empty);
});
test('load() aggregates data from all three repositories', () async {
final inventoryItems = [
const InventoryItem(
id: '1',
sku: 'A',
name: 'A',
quantityOnHand: 10,
unitPrice: 5.0,
status: InventoryStatus.inStock,
),
];
final drafts = [
ProductDraft(
id: '1',
name: 'Draft',
description: '',
price: 10,
sku: 'D',
category: 'Cat',
imageUrl: '',
status: PublishStatus.draft,
lastModified: DateTime(2026),
),
];
final orders = [
Order(
id: '1',
customerName: 'Test',
customerEmail: 'test@test.com',
orderDate: DateTime(2026),
status: OrderStatus.pending,
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 10.0)],
shippingAddress: '',
),
];
final controller = DashboardController(
GetDashboardSummary(
inventoryRepository: _StubInventoryRepository(inventoryItems),
productPublishingRepository: _StubProductPublishingRepository(drafts),
ordersRepository: _StubOrdersRepository(orders),
),
);
await controller.load();
expect(controller.summary.totalProducts, 1);
expect(controller.summary.inStock, 1);
expect(controller.summary.draftProducts, 1);
expect(controller.summary.totalOrders, 1);
expect(controller.summary.pendingOrders, 1);
});
test('subsequent load() clears previous error', () async {
final failingController = DashboardController(
GetDashboardSummary(
inventoryRepository: _FailingInventoryRepository(),
productPublishingRepository: _StubProductPublishingRepository(),
ordersRepository: _StubOrdersRepository(),
),
);
await failingController.load();
expect(failingController.error, isNotNull);
// Create a new controller with working repos to verify error clearing pattern.
final workingController = DashboardController(
GetDashboardSummary(
inventoryRepository: _StubInventoryRepository(),
productPublishingRepository: _StubProductPublishingRepository(),
ordersRepository: _StubOrdersRepository(),
),
);
await workingController.load();
expect(workingController.error, isNull);
});
});
}

View File

@ -0,0 +1,326 @@
import 'package:feature_inventory/feature_inventory.dart';
import 'package:feature_orders/feature_orders.dart';
import 'package:feature_wordpress/feature_wordpress.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:kell_web/dashboard/domain/dashboard_summary.dart';
void main() {
group('DashboardSummary.empty', () {
test('has all zeroes', () {
const s = DashboardSummary.empty;
expect(s.totalProducts, 0);
expect(s.inStock, 0);
expect(s.lowStock, 0);
expect(s.outOfStock, 0);
expect(s.draftProducts, 0);
expect(s.totalOrders, 0);
expect(s.pendingOrders, 0);
expect(s.activeOrders, 0);
expect(s.deliveredRevenue, 0.0);
});
});
group('DashboardSummary.fromData', () {
test('counts inventory statuses correctly', () {
final items = [
const InventoryItem(
id: '1',
sku: 'A',
name: 'A',
quantityOnHand: 10,
unitPrice: 5.0,
status: InventoryStatus.inStock,
),
const InventoryItem(
id: '2',
sku: 'B',
name: 'B',
quantityOnHand: 2,
unitPrice: 5.0,
status: InventoryStatus.lowStock,
),
const InventoryItem(
id: '3',
sku: 'C',
name: 'C',
quantityOnHand: 0,
unitPrice: 5.0,
status: InventoryStatus.outOfStock,
),
const InventoryItem(
id: '4',
sku: 'D',
name: 'D',
quantityOnHand: 0,
unitPrice: 5.0,
status: InventoryStatus.draft,
),
];
final summary = DashboardSummary.fromData(
inventoryItems: items,
productDrafts: [],
orders: [],
);
expect(summary.totalProducts, 4);
expect(summary.inStock, 1);
expect(summary.lowStock, 1);
expect(summary.outOfStock, 1);
});
test('counts draft products from publishing repository', () {
final drafts = [
ProductDraft(
id: '1',
name: 'A',
description: '',
price: 10,
sku: 'A',
category: 'Cat',
imageUrl: '',
status: PublishStatus.draft,
lastModified: DateTime(2026),
),
ProductDraft(
id: '2',
name: 'B',
description: '',
price: 10,
sku: 'B',
category: 'Cat',
imageUrl: '',
status: PublishStatus.published,
lastModified: DateTime(2026),
),
ProductDraft(
id: '3',
name: 'C',
description: '',
price: 10,
sku: 'C',
category: 'Cat',
imageUrl: '',
status: PublishStatus.draft,
lastModified: DateTime(2026),
),
];
final summary = DashboardSummary.fromData(
inventoryItems: [],
productDrafts: drafts,
orders: [],
);
expect(summary.draftProducts, 2);
});
test('counts order statuses and revenue correctly', () {
final orders = [
Order(
id: '1',
customerName: 'A',
customerEmail: 'a@a.com',
orderDate: DateTime(2026),
status: OrderStatus.pending,
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 10.0)],
shippingAddress: '',
),
Order(
id: '2',
customerName: 'B',
customerEmail: 'b@b.com',
orderDate: DateTime(2026),
status: OrderStatus.processing,
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 2, unitPrice: 5.0)],
shippingAddress: '',
),
Order(
id: '3',
customerName: 'C',
customerEmail: 'c@c.com',
orderDate: DateTime(2026),
status: OrderStatus.shipped,
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 20.0)],
shippingAddress: '',
),
Order(
id: '4',
customerName: 'D',
customerEmail: 'd@d.com',
orderDate: DateTime(2026),
status: OrderStatus.delivered,
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 3, unitPrice: 10.0)],
shippingAddress: '',
),
Order(
id: '5',
customerName: 'E',
customerEmail: 'e@e.com',
orderDate: DateTime(2026),
status: OrderStatus.cancelled,
items: const [OrderItem(productName: 'X', sku: 'X', quantity: 1, unitPrice: 100.0)],
shippingAddress: '',
),
];
final summary = DashboardSummary.fromData(
inventoryItems: [],
productDrafts: [],
orders: orders,
);
expect(summary.totalOrders, 5);
expect(summary.pendingOrders, 1);
expect(summary.activeOrders, 2); // processing + shipped
expect(summary.deliveredRevenue, 30.0); // 3 * 10
});
test('computes full summary from fake repository data', () {
// Use the same data the fake repositories return.
final summary = DashboardSummary.fromData(
inventoryItems: const [
InventoryItem(
id: '1',
sku: 'BC-FLR-001',
name: 'Floral Bowl Cozy',
quantityOnHand: 18,
unitPrice: 12.99,
status: InventoryStatus.inStock,
),
InventoryItem(
id: '2',
sku: 'CS-CIT-002',
name: 'Citrus Coaster Set',
quantityOnHand: 7,
unitPrice: 16.50,
status: InventoryStatus.lowStock,
),
InventoryItem(
id: '3',
sku: 'NL-OCN-003',
name: 'Ocean Nightlight',
quantityOnHand: 0,
unitPrice: 19.99,
status: InventoryStatus.outOfStock,
),
InventoryItem(
id: '4',
sku: 'JG-BLU-004',
name: 'Fabric Jar Gripper',
quantityOnHand: 23,
unitPrice: 8.50,
status: InventoryStatus.inStock,
),
InventoryItem(
id: '5',
sku: 'SH-SUN-005',
name: 'Skillet Handle Sleeve',
quantityOnHand: 5,
unitPrice: 10.99,
status: InventoryStatus.lowStock,
),
InventoryItem(
id: '6',
sku: 'SC-SUB-006',
name: 'Sublimated Slate Coaster',
quantityOnHand: 0,
unitPrice: 14.99,
status: InventoryStatus.draft,
),
],
productDrafts: [
ProductDraft(
id: '4',
name: 'Fabric Jar Gripper',
description: '',
price: 8.50,
sku: 'JG-BLU-004',
category: 'Kitchen Accessories',
imageUrl: '',
status: PublishStatus.draft,
lastModified: DateTime(2026, 4, 2),
),
ProductDraft(
id: '5',
name: 'Skillet Handle Sleeve',
description: '',
price: 10.99,
sku: 'SH-SUN-005',
category: 'Kitchen Accessories',
imageUrl: '',
status: PublishStatus.draft,
lastModified: DateTime(2026, 4, 3),
),
],
orders: [
Order(
id: 'KC-1001',
customerName: 'Sarah Mitchell',
customerEmail: 'sarah@example.com',
orderDate: DateTime(2026, 4, 1),
status: OrderStatus.delivered,
shippingAddress: '123 Maple St',
items: const [
OrderItem(
productName: 'Floral Bowl Cozy',
sku: 'BC-FLR-001',
quantity: 2,
unitPrice: 12.99,
),
OrderItem(
productName: 'Citrus Coaster Set',
sku: 'CS-CIT-002',
quantity: 1,
unitPrice: 16.50,
),
],
),
Order(
id: 'KC-1002',
customerName: 'James Thornton',
customerEmail: 'james@example.com',
orderDate: DateTime(2026, 4, 2),
status: OrderStatus.shipped,
shippingAddress: '456 Oak Ave',
items: const [
OrderItem(
productName: 'Ocean Nightlight',
sku: 'NL-OCN-003',
quantity: 1,
unitPrice: 19.99,
),
],
),
Order(
id: 'KC-1003',
customerName: 'Emily Chen',
customerEmail: 'emily@example.com',
orderDate: DateTime(2026, 4, 3),
status: OrderStatus.pending,
shippingAddress: '789 Pine Rd',
items: const [
OrderItem(
productName: 'Fabric Jar Gripper',
sku: 'JG-BLU-004',
quantity: 4,
unitPrice: 8.50,
),
],
),
],
);
expect(summary.totalProducts, 6);
expect(summary.inStock, 2);
expect(summary.lowStock, 2);
expect(summary.outOfStock, 1);
expect(summary.draftProducts, 2);
expect(summary.totalOrders, 3);
expect(summary.pendingOrders, 1);
expect(summary.activeOrders, 1); // shipped only
// delivered revenue: 2*12.99 + 1*16.50 = 42.48
expect(summary.deliveredRevenue, closeTo(42.48, 0.01));
});
});
}