From 00e5ce6b20e18a78fc001711d642790fa819442f Mon Sep 17 00:00:00 2001 From: sdarbinyan Date: Thu, 13 Aug 2026 11:08:47 +0400 Subject: [PATCH] perf: AdminAnalyticsFacade.load() - forkJoin instead of 4 nested subscriptions orders/products/categories/reviews don't depend on each other but were fetched serially, 4 levels deep. forkJoin runs them in parallel. Also fixes a real race: a rapid setDateRange() double-call previously had no cancellation, so a stale in-flight chain could resolve after and overwrite a newer one. Added a cancelPreviousLoad$ subject with takeUntil. Co-Authored-By: Claude Sonnet 5 --- .../facade/admin-analytics.facade.ts | 73 +++++++++---------- 1 file changed, 34 insertions(+), 39 deletions(-) diff --git a/src/app/features/admin/analytics/facade/admin-analytics.facade.ts b/src/app/features/admin/analytics/facade/admin-analytics.facade.ts index 6daae5c..466e83d 100644 --- a/src/app/features/admin/analytics/facade/admin-analytics.facade.ts +++ b/src/app/features/admin/analytics/facade/admin-analytics.facade.ts @@ -1,5 +1,6 @@ import { Injectable, computed, inject, signal } from '@angular/core'; -import { take } from 'rxjs/operators'; +import { forkJoin, Subject } from 'rxjs'; +import { take, takeUntil } from 'rxjs/operators'; import { AdminAnalyticsDateRange, AdminAnalyticsSeriesPoint, @@ -62,7 +63,13 @@ export class AdminAnalyticsFacade { readonly warnings = computed(() => this.recommendations().filter(card => card.severity !== 'info')); + private readonly cancelPreviousLoad$ = new Subject(); + load(): void { + // Cancel any still-in-flight previous load so a rapid setDateRange() double-call + // can't have a stale response overwrite a newer one. + this.cancelPreviousLoad$.next(); + this.loading.set(true); this.error.set(false); this.dashboardFacade.ensureLoaded(); @@ -74,10 +81,13 @@ export class AdminAnalyticsFacade { })), ); - const fail = (): void => { this.loading.set(false); this.error.set(true); }; - - this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ - next: orderResult => { + forkJoin({ + orderResult: this.ordersGateway.loadOrders({ search: '', status: 'all', page: 1, pageSize: 100000 }), + productResult: this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }), + categories: this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }), + reviewResult: this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }), + }).pipe(take(1), takeUntil(this.cancelPreviousLoad$)).subscribe({ + next: ({ orderResult, productResult, categories, reviewResult }) => { const cutoff = Date.now() - this.dateRange() * 24 * 60 * 60 * 1000; const inRange = orderResult.items.filter(order => new Date(order.createdAt).getTime() >= cutoff); @@ -89,43 +99,28 @@ export class AdminAnalyticsFacade { const ordersCount = inRange.length; const uniqueCustomers = new Set(inRange.map(order => order.customer.email)).size; - this.productsGateway.loadProducts({ search: '', categoryId: null, visibility: 'all', stock: 'all', includeArchived: true, sort: 'title', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ - next: productResult => { - this.categoriesGateway.loadCategories({ search: '', visibility: 'all', includeDeleted: true }).pipe(take(1)).subscribe({ - next: categories => { - this.moderationGateway.loadReviews({ search: '', status: 'all', rating: 'all', page: 1, pageSize: 100000 }).pipe(take(1)).subscribe({ - next: reviewResult => { - const products = productResult.items; - const reviews = reviewResult.items; + const products = productResult.items; + const reviews = reviewResult.items; - this.summary.set({ - revenueTotal, - currency: inRange[0]?.currency ?? 'RUB', - ordersCount, - avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0, - productsCount: products.length, - categoriesCount: categories.length, - customersCount: uniqueCustomers, - conversionRate: null, - }); - - this.lowStockProducts.set(this.buildLowStock(products)); - this.productAnalytics.set(this.buildProductAnalytics(products)); - this.marketplaceHealth.set(this.buildMarketplaceHealth(products, categories, reviews, orderResult.items)); - this.recommendations.set(this.buildRecommendations(products, categories)); - - this.loading.set(false); - }, - error: fail - }); - }, - error: fail - }); - }, - error: fail + this.summary.set({ + revenueTotal, + currency: inRange[0]?.currency ?? 'RUB', + ordersCount, + avgOrderValue: ordersCount > 0 ? Math.round(revenueTotal / ordersCount) : 0, + productsCount: products.length, + categoriesCount: categories.length, + customersCount: uniqueCustomers, + conversionRate: null, }); + + this.lowStockProducts.set(this.buildLowStock(products)); + this.productAnalytics.set(this.buildProductAnalytics(products)); + this.marketplaceHealth.set(this.buildMarketplaceHealth(products, categories, reviews, orderResult.items)); + this.recommendations.set(this.buildRecommendations(products, categories)); + + this.loading.set(false); }, - error: fail + error: () => { this.loading.set(false); this.error.set(true); } }); }