3 Commits

Author SHA1 Message Date
sdarbinyan
00e5ce6b20 perf: AdminAnalyticsFacade.load() - forkJoin instead of 4 nested subscriptions
Some checks failed
Architecture Governance / architecture (push) Has been cancelled
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 <noreply@anthropic.com>
2026-08-13 11:08:47 +04:00
sdarbinyan
a339a1c64e perf: debounce price-range/slider filter inputs
updateRange()/updateSlider() emitted stateChange synchronously on
every keystroke/drag event, triggering a full catalog filter
recompute each time. Debounced both (350ms, per filterId+key timer,
cleared on destroy).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:07:03 +04:00
sdarbinyan
c9a80da7c3 perf: memoize TranslatePipe instead of re-walking translations every CD cycle
pure:false stays (needed so language switches propagate without
touching every | translate template call site), but repeat calls with
unchanged key/params/language now hit a Map lookup instead of
re-splitting the key and re-walking the translation object tree.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:03:30 +04:00
3 changed files with 109 additions and 59 deletions

View File

@@ -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<void>();
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); }
});
}

View File

@@ -1,8 +1,10 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, signal } from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnDestroy, Output, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FilterGroup, SearchFilterState } from '../../../../../core/search/models/search.model';
import { TranslatePipe } from '../../../../../i18n/translate.pipe';
const RANGE_DEBOUNCE_MS = 350;
@Component({
selector: 'app-catalog-filters-panel',
standalone: true,
@@ -11,12 +13,20 @@ import { TranslatePipe } from '../../../../../i18n/translate.pipe';
styleUrls: ['./filters-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush
})
export class CatalogFiltersPanelComponent {
export class CatalogFiltersPanelComponent implements OnDestroy {
@Input() definitions: FilterGroup[] = [];
@Input() state: SearchFilterState = { values: {}, ranges: {}, toggles: {} };
@Output() stateChange = new EventEmitter<SearchFilterState>();
private readonly debounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
ngOnDestroy(): void {
for (const timer of this.debounceTimers.values()) {
clearTimeout(timer);
}
}
readonly collapsed = signal<Record<string, boolean>>({});
toggleGroup(filterId: string): void {
@@ -79,34 +89,51 @@ export class CatalogFiltersPanelComponent {
updateRange(filterId: string, key: 'min' | 'max', rawValue: string): void {
const value = rawValue.trim().length ? Number(rawValue) : undefined;
const current = this.state.ranges[filterId] ?? {};
this.stateChange.emit({
...this.state,
ranges: {
...this.state.ranges,
[filterId]: {
...current,
[key]: Number.isFinite(value as number) ? value : undefined
this.debounce(`range:${filterId}:${key}`, () => {
const current = this.state.ranges[filterId] ?? {};
this.stateChange.emit({
...this.state,
ranges: {
...this.state.ranges,
[filterId]: {
...current,
[key]: Number.isFinite(value as number) ? value : undefined
}
}
}
});
});
}
updateSlider(filterId: string, value: string): void {
const numeric = Number(value);
this.stateChange.emit({
...this.state,
ranges: {
...this.state.ranges,
[filterId]: {
...this.state.ranges[filterId],
max: Number.isFinite(numeric) ? numeric : undefined,
this.debounce(`slider:${filterId}`, () => {
this.stateChange.emit({
...this.state,
ranges: {
...this.state.ranges,
[filterId]: {
...this.state.ranges[filterId],
max: Number.isFinite(numeric) ? numeric : undefined,
},
},
},
});
});
}
/** Debounces range/slider input so full catalog filter recompute doesn't run on every keystroke/drag event. */
private debounce(key: string, action: () => void): void {
const existing = this.debounceTimers.get(key);
if (existing) {
clearTimeout(existing);
}
this.debounceTimers.set(key, setTimeout(() => {
this.debounceTimers.delete(key);
action();
}, RANGE_DEBOUNCE_MS));
}
updateToggle(filterId: string, checked: boolean): void {
this.stateChange.emit({
...this.state,

View File

@@ -1,14 +1,42 @@
import { Pipe, PipeTransform, inject } from '@angular/core';
import { TranslateService } from './translate.service';
import { LanguageService } from '../services/language.service';
/**
* Stays impure (must re-run every CD cycle so a live language switch
* updates every binding without touching hundreds of `| translate`
* template call sites to pass the language explicitly as a pure-pipe
* argument). Memoized per-instance instead: repeat calls with the same
* key/params/language - the overwhelming majority of CD cycles, since
* nothing actually changed - hit a Map lookup instead of re-walking the
* translation tree and re-running the interpolation regex.
*/
@Pipe({
name: 'translate',
pure: false,
})
export class TranslatePipe implements PipeTransform {
private translateService = inject(TranslateService);
private langService = inject(LanguageService);
private lastLang = '';
private readonly cache = new Map<string, string>();
transform(key: string, params?: Record<string, string | number>): string {
return this.translateService.t(key, params);
const lang = this.langService.currentLanguage();
if (lang !== this.lastLang) {
this.lastLang = lang;
this.cache.clear();
}
const cacheKey = params ? `${key}::${JSON.stringify(params)}` : key;
const cached = this.cache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const result = this.translateService.t(key, params);
this.cache.set(cacheKey, result);
return result;
}
}