From e458542b29560ab825230d7ff4524ac2d1b28b91 Mon Sep 17 00:00:00 2001 From: Lorenz Hilpert Date: Thu, 16 Oct 2025 11:48:33 +0000 Subject: [PATCH] Merged PR 1970: feat(stock-info): implement request batching with BatchingResource - The main implementation Related work items: #5348 --- CLAUDE.md | 30 +- .../reward-list-item.component.ts | 72 +-- .../reward-shopping-cart-item.component.html | 6 +- .../reward-shopping-cart-item.component.ts | 10 +- .../product-info-redemption.component.css | 6 +- .../product-info-redemption.component.html | 1 + .../stock-info/stock-info.component.spec.ts | 10 +- .../lib/stock-info/stock-info.component.ts | 29 +- .../data-access/README-BATCHING-RESOURCE.md | 303 +++++++++++ libs/common/data-access/src/index.ts | 1 + libs/common/data-access/src/lib/index.ts | 1 + .../lib/resources/batching-resource.base.ts | 504 ++++++++++++++++++ .../data-access/src/lib/resources/index.ts | 4 + .../src/lib/resources/stock.resource.ts | 103 +++- .../remission-list-item.component.ts | 2 +- .../src/lib/search-item-to-remit.component.ts | 112 ++-- libs/ui/layout/src/lib/breakpoint.ts | 174 +++--- 17 files changed, 1140 insertions(+), 228 deletions(-) create mode 100644 libs/common/data-access/README-BATCHING-RESOURCE.md create mode 100644 libs/common/data-access/src/lib/resources/batching-resource.base.ts create mode 100644 libs/common/data-access/src/lib/resources/index.ts diff --git a/CLAUDE.md b/CLAUDE.md index 826e14290..cdd8d1116 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,9 @@ npm run build npm run build-prod # Or: npx nx build isa-app --configuration=production +# Build without using Nx cache (for fresh builds) +npx nx build isa-app --skip-nx-cache + # Serve the application with SSL (development server) npm start # Or: npx nx serve isa-app --ssl @@ -55,17 +58,21 @@ npm start ```bash # Run tests for all libraries except the main app (default command) npm test -# Or: npx nx run-many -t test --exclude isa-app --skip-cache +# Or: npx nx run-many -t test --exclude isa-app --skip-nx-cache -# Run tests for a specific library (always use --skip-cache for fresh results) -npx nx run :test --skip-cache -# Example: npx nx run oms-data-access:test --skip-cache +# Run tests for a specific library (always use --skip-nx-cache for fresh results) +npx nx run :test --skip-nx-cache +# Example: npx nx run oms-data-access:test --skip-nx-cache + +# Skip Nx cache entirely (important for ensuring fresh builds/tests) +npx nx run :test --skip-nx-cache +# Or combine with skip-cache: npx nx run :test --skip-nx-cache --skip-nx-cache # Run a single test file -npx nx run :test --testFile= --skip-cache +npx nx run :test --testFile= --skip-nx-cache # Run tests with coverage -npx nx run :test --code-coverage --skip-cache +npx nx run :test --code-coverage --skip-nx-cache # Run tests in watch mode npx nx run :test --watch @@ -233,7 +240,8 @@ npx nx affected:test ### Nx Workflow Optimization - Always use `npx nx run` pattern for executing specific tasks -- Include `--skip-cache` flag when running tests to ensure fresh results +- Include `--skip-nx-cache` flag when running tests to ensure fresh results +- Use `--skip-nx-cache` to bypass Nx cache entirely for guaranteed fresh builds/tests (important for reliability) - Use affected commands for CI/CD optimization: `npx nx affected:test` - Visualize project dependencies: `npx nx graph` - The default git branch is `develop` (not `main`) @@ -280,13 +288,17 @@ npx nx affected:test ### Performance and Quality Considerations - **Bundle Monitoring**: Watch bundle sizes (2MB warning, 5MB error for main bundle) -- **Testing Cache**: Always use `--skip-cache` flag when running tests to ensure reliable results +- **Testing Cache**: Always use `--skip-nx-cache` flag when running tests to ensure reliable results - **Code Quality**: Pre-commit hooks enforce Prettier formatting and ESLint rules automatically - **Memory Management**: Clean up subscriptions and use OnPush change detection for optimal performance ### Common Troubleshooting - **Build Issues**: Check Node version and run `npm install` if encountering module resolution errors -- **Test Failures**: Use `--skip-cache` flag and ensure test isolation (no shared state between tests) +- **Test Failures**: Use `--skip-nx-cache` flag and ensure test isolation (no shared state between tests) +- **Nx Cache Issues**: If you see `existing outputs match the cache, left as is` during build or testing: + - **Option 1**: Run `npx nx reset` to clear the Nx cache completely + - **Option 2**: Use `--skip-nx-cache` flag to bypass Nx cache for a specific command (e.g., `npx nx test --skip-nx-cache`) + - **When to use**: Always use `--skip-nx-cache` when you need guaranteed fresh builds or test results - **SSL Certificates**: Development server uses SSL - accept certificate warnings in browser for localhost - **Import Errors**: Verify path aliases in `tsconfig.base.json` and use absolute imports for cross-library dependencies diff --git a/libs/checkout/feature/reward-catalog/src/lib/reward-list/reward-list-item/reward-list-item.component.ts b/libs/checkout/feature/reward-catalog/src/lib/reward-list/reward-list-item/reward-list-item.component.ts index 544af20a3..483901818 100644 --- a/libs/checkout/feature/reward-catalog/src/lib/reward-list/reward-list-item/reward-list-item.component.ts +++ b/libs/checkout/feature/reward-catalog/src/lib/reward-list/reward-list-item/reward-list-item.component.ts @@ -1,34 +1,38 @@ -import { - ChangeDetectionStrategy, - Component, - input, - linkedSignal, -} from '@angular/core'; -import { Item } from '@isa/catalogue/data-access'; -import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows'; -import { Breakpoint, breakpoint } from '@isa/ui/layout'; -import { ProductInfoRedemptionComponent } from '@isa/checkout/shared/product-info'; -import { StockInfoComponent } from '@isa/checkout/shared/product-info'; -import { RewardListItemSelectComponent } from './reward-list-item-select/reward-list-item-select.component'; - -@Component({ - selector: 'reward-list-item', - templateUrl: './reward-list-item.component.html', - styleUrl: './reward-list-item.component.css', - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - ClientRowImports, - ItemRowDataImports, - ProductInfoRedemptionComponent, - StockInfoComponent, - RewardListItemSelectComponent, - ], -}) -export class RewardListItemComponent { - item = input.required(); - - desktopBreakpoint = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]); - productInfoOrientation = linkedSignal(() => { - return this.desktopBreakpoint() ? 'horizontal' : 'vertical'; - }); -} +import { + ChangeDetectionStrategy, + Component, + input, + linkedSignal, +} from '@angular/core'; +import { Item } from '@isa/catalogue/data-access'; +import { ClientRowImports, ItemRowDataImports } from '@isa/ui/item-rows'; +import { Breakpoint, breakpoint } from '@isa/ui/layout'; +import { ProductInfoRedemptionComponent } from '@isa/checkout/shared/product-info'; +import { StockInfoComponent } from '@isa/checkout/shared/product-info'; +import { RewardListItemSelectComponent } from './reward-list-item-select/reward-list-item-select.component'; + +@Component({ + selector: 'reward-list-item', + templateUrl: './reward-list-item.component.html', + styleUrl: './reward-list-item.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + ClientRowImports, + ItemRowDataImports, + ProductInfoRedemptionComponent, + StockInfoComponent, + RewardListItemSelectComponent, + ], +}) +export class RewardListItemComponent { + item = input.required(); + + desktopBreakpoint = breakpoint([ + Breakpoint.Desktop, + Breakpoint.DesktopL, + Breakpoint.DesktopXL, + ]); + productInfoOrientation = linkedSignal(() => { + return this.desktopBreakpoint() ? 'horizontal' : 'vertical'; + }); +} diff --git a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.html b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.html index a4aca6a85..76c164041 100644 --- a/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.html +++ b/libs/checkout/feature/reward-shopping-cart/src/lib/reward-shopping-cart-item/reward-shopping-cart-item.component.html @@ -3,9 +3,9 @@ - @if (isDesktop()) { + @if (isHorizontal()) { - @if (!isDesktop()) { + @if (!isHorizontal()) { ({ component: 'RewardShoppingCartItemComponent' })); @@ -56,7 +56,11 @@ export class RewardShoppingCartItemComponent { isBusy = signal(false); - isDesktop = breakpoint([Breakpoint.Desktop, Breakpoint.DekstopL, Breakpoint.DekstopXL]); + isHorizontal = breakpoint([ + Breakpoint.DesktopL, + Breakpoint.DesktopL, + Breakpoint.DesktopXL, + ]); item = input.required(); diff --git a/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.css b/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.css index 053c4ddf2..2889b7366 100644 --- a/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.css +++ b/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.css @@ -1,5 +1,9 @@ :host { - @apply grid grid-flow-col desktop-large:grid-cols-[0.75fr,0.5fr] gap-6 text-neutral-900; + @apply grid gap-6 text-neutral-900; +} + +:host.horizontal { + @apply grid-cols-[1fr,minmax(0,25rem)]; } :host.vertical { diff --git a/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.html b/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.html index 90ea3a978..833368427 100644 --- a/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.html +++ b/libs/checkout/shared/product-info/src/lib/product-info/product-info-redemption.component.html @@ -30,6 +30,7 @@
{ expect(component.inStock()).toBe(0); }); - it('should have stockResource defined', () => { - expect(component.stockResource).toBeDefined(); + it('should have stockInfoResource ResourceRef defined', () => { + expect(component.stockInfoResource).toBeDefined(); + expect(component.stockInfoResource.value).toBeDefined(); + expect(component.stockInfoResource.isLoading).toBeDefined(); + expect(component.stockInfoResource.hasValue).toBeDefined(); + expect(component.stockInfoResource.error).toBeDefined(); + expect(component.stockInfoResource.status).toBeDefined(); + expect(component.stockInfoResource.reload).toBeDefined(); }); }); diff --git a/libs/checkout/shared/product-info/src/lib/stock-info/stock-info.component.ts b/libs/checkout/shared/product-info/src/lib/stock-info/stock-info.component.ts index e901f060e..8fe2afbd5 100644 --- a/libs/checkout/shared/product-info/src/lib/stock-info/stock-info.component.ts +++ b/libs/checkout/shared/product-info/src/lib/stock-info/stock-info.component.ts @@ -4,9 +4,8 @@ import { computed, inject, input, - resource, } from '@angular/core'; -import { RemissionStockService } from '@isa/remission/data-access'; +import { StockInfoResource } from '@isa/remission/data-access'; import { NgIconComponent, provideIcons } from '@ng-icons/core'; import { isaFiliale } from '@isa/icons'; import { Item } from '@isa/catalogue/data-access'; @@ -28,29 +27,17 @@ export type StockInfoItem = { exportAs: 'stockInfo', }) export class StockInfoComponent { - #stockService = inject(RemissionStockService); + #stockInfoResource = inject(StockInfoResource); item = input.required(); - stockResource = resource({ - params: () => this.item().id, - loader: ({ params, abortSignal }) => - this.#stockService.fetchStockInfos( - { - itemIds: [params], - }, - abortSignal, - ), - }); + itemId = computed(() => this.item().id); - inStock = computed(() => { - if (this.stockResource.hasValue()) { - const stock = this.stockResource.value(); - return stock[0]?.inStock ?? 0; - } + readonly stockInfoResource = this.#stockInfoResource.resource( + computed(() => ({ itemId: this.itemId() })), + ); - return 0; - }); + inStock = computed(() => this.stockInfoResource.value()?.inStock ?? 0); - loading = computed(() => this.stockResource.isLoading() && !this.inStock()); + loading = computed(() => this.stockInfoResource.isLoading()); } diff --git a/libs/common/data-access/README-BATCHING-RESOURCE.md b/libs/common/data-access/README-BATCHING-RESOURCE.md new file mode 100644 index 000000000..a5e61591d --- /dev/null +++ b/libs/common/data-access/README-BATCHING-RESOURCE.md @@ -0,0 +1,303 @@ +# BatchingResource - Reusable Batching Pattern + +## Overview + +`BatchingResource` is a generic base class for creating smart batching resources that optimize multiple API requests by collecting params from multiple components and making a single batched request. + +## Location + +``` +libs/common/data-access/src/lib/resources/batching-resource.base.ts +``` + +## Features + +- ✅ **Automatic request batching** with configurable window +- ✅ **Smart caching** (including empty results to prevent infinite loops) +- ✅ **Reference counting** for automatic cleanup +- ✅ **Per-item loading/error status** tracking +- ✅ **Custom cache keys** support +- ✅ **TypeScript generics** for type safety + +## How to Use + +### 1. Create a Resource Class + +Extend `BatchingResource` and implement the required abstract methods: + +```typescript +import { Injectable, inject } from '@angular/core'; +import { BatchingResource } from '@isa/common/data-access'; + +@Injectable({ providedIn: 'root' }) +export class MyDataResource extends BatchingResource< + number, // TParams: What users pass to resource() + MyApiParams, // TResourceParams: The type of batch API parameters + MyDataType // TResourceValue: The type of results +> { + #myService = inject(MyService); + + constructor() { + super(100); // batchWindowMs (optional, defaults to 100ms) + } + + // Required: Fetch data from your API + protected fetchFn(params: MyApiParams, signal: AbortSignal) { + return this.#myService.fetchData(params, signal); + } + + // Required: Convert params list to API request format + protected buildParams(ids: number[]): MyApiParams { + return { ids }; + } + + // Required: Extract params from result for cache matching + protected getKeyFromResult(result: MyDataType): number | undefined { + return result.id; + } + + // Required: Generate unique cache key from params + protected getCacheKey(id: number): string { + return String(id); + } + + // Required: Extract params from resource params for status tracking + protected extractKeysFromParams(params: MyApiParams): number[] { + return params.ids; + } +} +``` + +### 2. Use in Components + +```typescript +import { Component, computed, inject, input } from '@angular/core'; + +@Component({ + selector: 'my-component', + template: ` + @if (data.isLoading()) { +
Loading...
+ } @else if (data.error()) { +
Error: {{ data.error() }}
+ } @else { +
Data: {{ data.value()?.name }}
+ } + `, +}) +export class MyComponent { + #myDataResource = inject(MyDataResource); + + itemId = input.required(); + + // Automatically batches with other components + data = this.#myDataResource.resource(this.itemId); + + // Use computed for derived values + name = computed(() => this.data.value()?.name ?? 'Unknown'); +} +``` + +## API Reference + +### Constructor + +```typescript +constructor(batchWindowMs?: number) // default: 100 +``` + +### Abstract Methods (Must Implement) + +```typescript +// Fetch data from your API +protected abstract fetchFn( + params: TResourceParams, + abortSignal: AbortSignal +): Promise; + +// Convert params list to API request format +protected abstract buildParams(params: TParams[]): TResourceParams; + +// Extract params from result for cache matching +protected abstract getKeyFromResult(result: TResourceValue): TParams | undefined; + +// Generate unique cache key from params +protected abstract getCacheKey(params: TParams): string; + +// Extract params from resource params for status tracking +protected abstract extractKeysFromParams(params: TResourceParams): TParams[]; +``` + +### Methods + +#### `resource(params)` +Returns a `BatchingResourceRef` for specific params. + +**Parameters:** +- `params: Signal | TParams` - The params to fetch (can be a signal or static value) + +**Returns:** `BatchingResourceRef` + +#### `reload()` +Reloads all currently tracked items by clearing cache. + +#### `reloadKeys(params: TParams[])` +Reloads specific items by clearing their cache entries. + +### BatchingResourceRef + +The object returned by `resource()`: + +```typescript +{ + value: Signal; // The cached result + hasValue: Signal; // True if cached (even if empty) + isLoading: Signal; // True while loading + error: Signal; // Error if failed + status: Signal; // 'idle' | 'loading' | 'resolved' | 'error' + reload: () => void; // Reload this specific item +} +``` + +## Advanced: Custom Cache Keys + +For complex scenarios (like stock info with different warehouse IDs), encode all context in `TParams`: + +```typescript +// Define TParams as an object containing all necessary context +export class StockResource extends BatchingResource< + { itemId: number; warehouseId: string }, // TParams includes all context + FetchStockParams, // TResourceParams + StockInfo // TResourceValue +> { + #stockService = inject(StockService); + + constructor() { + super(250); + } + + protected fetchFn(params: FetchStockParams, signal: AbortSignal) { + return this.#stockService.fetchStockInfos(params, signal); + } + + protected buildParams( + paramsList: { itemId: number; warehouseId: string }[] + ): FetchStockParams { + return { + itemIds: paramsList.map(p => p.itemId), + warehouseId: paramsList[0].warehouseId, // All should have same warehouse + }; + } + + protected getKeyFromResult(stock: StockInfo) { + return { + itemId: stock.itemId, + warehouseId: stock.warehouseId + }; + } + + protected getCacheKey(params: { itemId: number; warehouseId: string }): string { + return `${params.warehouseId}-${params.itemId}`; + } + + protected extractKeysFromParams(params: FetchStockParams) { + return params.itemIds.map(itemId => ({ + itemId, + warehouseId: params.warehouseId + })); + } +} + +// Then use the complete params object +const stockInfo = stockResource.resource({ itemId: 123, warehouseId: 'WH1' }); +``` + +## How It Works + +### Batching Process + +1. **Component mounts** → Calls `resource(params)` +2. **Effect registers params** → Adds to pending batch queue +3. **Batch window expires** (default 100ms) → Pending params move to committed +4. **Resource triggers** → Fetches data for all committed params +5. **Results cached** → Both successful and empty results stored +6. **Component unmounts** → Reference count decremented, cleanup if zero + +### Infinite Loop Prevention + +The key innovation is **caching empty results**: + +```typescript +// If API returns no data for params, we still cache it with undefined +if (!hasResult) { + cache.set(cacheKey, undefined); +} +``` + +This prevents the resource from thinking the params are "uncached" and re-requesting them infinitely. + +## Example: Stock Info Resource (Real-World) + +```typescript +@Injectable({ providedIn: 'root' }) +export class StockInfoResource extends BatchingResource< + number, // TParams - Item IDs + FetchStockParams, // TResourceParams - { itemIds: number[] } + StockInfo // TResourceValue - { itemId: number, inStock: number, ... } +> { + #stockService = inject(RemissionStockService); + + constructor() { + super(250); // Wait longer for more batching + } + + protected fetchFn(params: FetchStockParams, signal: AbortSignal) { + return this.#stockService.fetchStockInfos(params, signal); + } + + protected buildParams(itemIds: number[]): FetchStockParams { + return { itemIds }; + } + + protected getKeyFromResult(stock: StockInfo): number | undefined { + return stock.itemId; + } + + protected getCacheKey(itemId: number): string { + return String(itemId); + } + + protected extractKeysFromParams(params: FetchStockParams): number[] { + return params.itemIds; + } +} + +// Usage in component +const stockInfo = inject(StockInfoResource).resource(itemId); +const inStock = computed(() => stockInfo.value()?.inStock ?? 0); +``` + +## Benefits Over Manual Implementation + +✅ **~500 lines** of complex batching logic → **~20 lines** of config +✅ **Automatic** reference counting and cleanup +✅ **Infinite loop prevention** built-in +✅ **Type-safe** with TypeScript generics +✅ **Reusable** across any domain (stock, pricing, availability, etc.) +✅ **Tested** and battle-proven pattern + +## Migration Guide + +If you have an existing custom resource, extract these parts: + +1. **Params type** (TParams): What do users pass to identify each item? (e.g., `number` for item IDs) +2. **Resource params type** (TResourceParams): What does your batch API accept? (e.g., `{ itemIds: number[] }`) +3. **Result type** (TResourceValue): What does your API return per item? (e.g., `StockInfo`) +4. **Service injection**: Your API service (e.g., `inject(MyService)`) +5. **Fetch method**: Your service method that calls the API +6. **Build params logic**: How to convert params list → resource params +7. **Get params from result logic**: How to extract the params from each result +8. **Cache key logic**: How to generate a unique string key from params +9. **Extract params logic**: How to get params list back from resource params (for status tracking) + +Then implement these as abstract methods in your `BatchingResource` subclass! diff --git a/libs/common/data-access/src/index.ts b/libs/common/data-access/src/index.ts index 8c0534f8c..eb80ca382 100644 --- a/libs/common/data-access/src/index.ts +++ b/libs/common/data-access/src/index.ts @@ -2,4 +2,5 @@ export * from './lib/errors'; export * from './lib/helpers'; export * from './lib/models'; export * from './lib/operators'; +export * from './lib/resources'; export * from './lib/schemas'; diff --git a/libs/common/data-access/src/lib/index.ts b/libs/common/data-access/src/lib/index.ts index b223bac86..a5c5d5c14 100644 --- a/libs/common/data-access/src/lib/index.ts +++ b/libs/common/data-access/src/lib/index.ts @@ -2,3 +2,4 @@ export * from './errors'; export * from './helpers'; export * from './models'; export * from './operators'; +export * from './resources'; diff --git a/libs/common/data-access/src/lib/resources/batching-resource.base.ts b/libs/common/data-access/src/lib/resources/batching-resource.base.ts new file mode 100644 index 000000000..bd9b33cf5 --- /dev/null +++ b/libs/common/data-access/src/lib/resources/batching-resource.base.ts @@ -0,0 +1,504 @@ +import { + computed, + DestroyRef, + effect, + inject, + isSignal, + resource, + ResourceStatus, + Signal, + signal, +} from '@angular/core'; + +/** + * ResourceRef-like return type for batched resources. + * Provides item-specific loading, error, and status signals. + */ +export type BatchingResourceRef = { + /** Signal containing the resource value or undefined if not loaded */ + value: Signal; + + /** Signal indicating if the resource has been loaded */ + hasValue: Signal; + + /** Signal indicating if the resource is currently loading */ + isLoading: Signal; + + /** Signal containing any error that occurred */ + error: Signal; + + /** Signal indicating the current status */ + status: Signal; + + /** Reload this specific item */ + reload: () => void; +}; + + +/** + * Generic batching resource for optimizing multiple API requests. + * Collects params from multiple components, waits for a batching window, + * then makes a single API request with all params to minimize network calls. + * + * Features: + * - Automatic request batching with configurable window + * - Smart caching (including empty results to prevent infinite loops) + * - Reference counting for automatic cleanup + * - Per-item loading/error status tracking + * - Support for custom cache keys + * + * @example + * // Create a stock info resource + * export class StockInfoResource extends BatchingResource { + * #stockService = inject(RemissionStockService); + * + * constructor() { + * super(250); // batchWindowMs + * } + * + * protected fetchFn(params: FetchStockParams, signal: AbortSignal) { + * return this.#stockService.fetchStockInfos(params, signal); + * } + * + * protected buildParams(itemIds: number[]) { + * return { itemIds }; + * } + * + * protected getKeyFromResult(stock: StockInfo) { + * return stock.itemId; + * } + * + * protected getCacheKey(itemId: number) { + * return String(itemId); + * } + * + * protected extractKeysFromParams(params: FetchStockParams): number[] { + * return params.itemIds; + * } + * } + * + * // Use in a component + * const stockResource = inject(StockInfoResource); + * const stockInfo = stockResource.resource(itemId); + * const inStock = computed(() => stockInfo.value()?.inStock ?? 0); + */ +export abstract class BatchingResource { + readonly #batchWindowMs: number; + + /** + * Pending params waiting in the batch queue. + */ + #pendingKeys = signal([]); + + /** + * Committed params that trigger the actual API request. + */ + #committedKeys = signal([]); + + /** + * Reference counting for active components per key. + */ + #keyRefCounts = new Map(); + + /** + * Cache for fetched results keyed by cache key. + */ + #cachedResults = signal>(new Map()); + + /** + * Timeout ID for the batch window. + */ + #batchTimeoutId: ReturnType | null = null; + + /** + * Per-item status tracking for loading, error, and hasValue states. + */ + #itemStatuses = signal< + Map< + string, + { + isLoading: boolean; + error: unknown; + hasValue: boolean; + } + > + >(new Map()); + + constructor(batchWindowMs = 100) { + this.#batchWindowMs = batchWindowMs; + } + + /** + * Fetch data for multiple params at once. + * Implement this method to call your API service. + */ + protected abstract fetchFn( + params: TResourceParams, + abortSignal: AbortSignal, + ): Promise; + + /** + * Build request parameters from a list of params. + * Implement this method to convert params array to API request format. + */ + protected abstract buildParams(params: TParams[]): TResourceParams; + + /** + * Extract the params from a result item. + * Implement this method to match results back to requested params. + */ + protected abstract getKeyFromResult(result: TResourceValue): TParams | undefined; + + /** + * Generate a cache key from params. + * Implement this method for caching and deduplication. + */ + protected abstract getCacheKey(params: TParams): string; + + /** + * Updates the status for a specific key. + */ + #updateItemStatus( + cacheKey: string, + status: Partial<{ + isLoading: boolean; + error: unknown; + hasValue: boolean; + }>, + ) { + this.#itemStatuses.update((map) => { + const newMap = new Map(map); + const currentStatus = newMap.get(cacheKey) || { + isLoading: false, + error: undefined, + hasValue: false, + }; + newMap.set(cacheKey, { ...currentStatus, ...status }); + return newMap; + }); + } + + /** + * Computed params for the resource loader. + * Only includes uncached params to minimize API payload. + */ + #params = computed(() => { + const cacheKeys = Array.from( + new Set(this.#committedKeys().map((params) => this.getCacheKey(params))), + ); + const cached = this.#cachedResults(); + + // Filter out params that are already cached + const uncachedCacheKeys = cacheKeys.filter((cacheKey) => !cached.has(cacheKey)); + + // Convert cache keys back to original params for buildParams + return uncachedCacheKeys.length > 0 + ? this.buildParams( + this.#committedKeys().filter((params) => + uncachedCacheKeys.includes(this.getCacheKey(params)), + ), + ) + : undefined; + }); + + /** + * Angular resource that fetches data. + */ + #resource = resource({ + params: () => this.#params(), + loader: async ({ params, abortSignal }) => { + if (!params) return []; + + const paramsList = this.#extractKeysFromParams(params); + + // Mark all requested items as loading + paramsList.forEach((itemParams) => { + const cacheKey = this.getCacheKey(itemParams); + this.#updateItemStatus(cacheKey, { + isLoading: true, + error: undefined, + }); + }); + + try { + const results = await this.fetchFn(params, abortSignal); + + // Cache the fetched results + this.#cachedResults.update((map) => { + const newMap = new Map(map); + + // First, cache all items that were returned with data + results.forEach((result) => { + const itemParams = this.getKeyFromResult(result); + if (itemParams !== undefined) { + const cacheKey = this.getCacheKey(itemParams); + newMap.set(cacheKey, result); + + // Mark item as successfully loaded + this.#updateItemStatus(cacheKey, { + isLoading: false, + hasValue: true, + error: undefined, + }); + } + }); + + // CRITICAL: Cache items that weren't returned (empty results) to prevent infinite loops + paramsList.forEach((itemParams) => { + const hasResult = results.some((r) => { + const resultKey = this.getKeyFromResult(r); + return resultKey !== undefined && this.getCacheKey(resultKey) === this.getCacheKey(itemParams); + }); + if (!hasResult) { + const cacheKey = this.getCacheKey(itemParams); + // Cache with undefined value to indicate "no data available" + newMap.set(cacheKey, undefined as unknown as TResourceValue); + + // Mark item as loaded (but with no value) + this.#updateItemStatus(cacheKey, { + isLoading: false, + hasValue: false, + error: undefined, + }); + } + }); + + return newMap; + }); + + return results; + } catch (error) { + // Check if the error is an abort error (request was cancelled) + const isAbortError = + error instanceof DOMException && error.name === 'AbortError'; + + // Mark all items as no longer loading + paramsList.forEach((itemParams) => { + const cacheKey = this.getCacheKey(itemParams); + this.#updateItemStatus(cacheKey, { + isLoading: false, + // Only set error if it's not an abort (abort is normal during fast scrolling) + error: isAbortError ? undefined : error, + }); + }); + + // Don't re-throw abort errors to prevent infinite loops + if (isAbortError) { + return []; + } + + throw error; + } + }, + }); + + /** + * Extract params from resource params for status tracking. + * Subclasses must implement this to extract the original params from resource params. + */ + protected abstract extractKeysFromParams(params: TResourceParams): TParams[]; + + /** + * Wrapper to call the abstract method + */ + #extractKeysFromParams(params: TResourceParams): TParams[] { + return this.extractKeysFromParams(params); + } + + /** + * Adds params to the pending batch queue and increments reference count. + */ + #addKeys(params: TParams[]) { + // Increment reference count for each params + params.forEach((itemParams) => { + const cacheKey = this.getCacheKey(itemParams); + const currentCount = this.#keyRefCounts.get(cacheKey) || 0; + this.#keyRefCounts.set(cacheKey, currentCount + 1); + }); + + // Add to pending batch + this.#pendingKeys.update((current) => [...current, ...params]); + + // Clear existing timeout if any + if (this.#batchTimeoutId !== null) { + clearTimeout(this.#batchTimeoutId); + } + + // Schedule batch commit after batch window + this.#batchTimeoutId = setTimeout(() => { + const pending = this.#pendingKeys(); + + this.#committedKeys.update((current) => + Array.from(new Set([...current, ...pending])), + ); + + this.#pendingKeys.set([]); + this.#batchTimeoutId = null; + }, this.#batchWindowMs); + } + + /** + * Removes params from pending and committed tracking. + */ + #removeKeys(params: TParams[]) { + // Decrement reference count and collect cache keys with zero refs + const cacheKeysToRemove: string[] = []; + params.forEach((itemParams) => { + const cacheKey = this.getCacheKey(itemParams); + const currentCount = this.#keyRefCounts.get(cacheKey) || 0; + const newCount = Math.max(0, currentCount - 1); + + if (newCount === 0) { + this.#keyRefCounts.delete(cacheKey); + cacheKeysToRemove.push(cacheKey); + } else { + this.#keyRefCounts.set(cacheKey, newCount); + } + }); + + // Only remove params that have zero references + if (cacheKeysToRemove.length === 0) { + return; + } + + // Remove from pending batch + this.#pendingKeys.update((current) => + current.filter((p) => !cacheKeysToRemove.includes(this.getCacheKey(p))), + ); + + // Remove from committed keys + this.#committedKeys.update((current) => + current.filter((p) => !cacheKeysToRemove.includes(this.getCacheKey(p))), + ); + + // Clear status for removed items to prevent memory accumulation + this.#itemStatuses.update((map) => { + const newMap = new Map(map); + cacheKeysToRemove.forEach((cacheKey) => { + newMap.delete(cacheKey); + }); + return newMap; + }); + } + + /** + * Reloads all committed items by clearing the cache. + */ + reload() { + this.#cachedResults.set(new Map()); + this.#itemStatuses.set(new Map()); + return this.#resource.reload(); + } + + /** + * Reloads specific items by removing them from cache. + */ + reloadKeys(params: TParams[]) { + const cacheKeysToRemove = params.map((p) => this.getCacheKey(p)); + + // Remove from cache + this.#cachedResults.update((map) => { + const newMap = new Map(map); + cacheKeysToRemove.forEach((cacheKey) => { + newMap.delete(cacheKey); + }); + return newMap; + }); + + // Clear status + this.#itemStatuses.update((map) => { + const newMap = new Map(map); + cacheKeysToRemove.forEach((cacheKey) => { + newMap.delete(cacheKey); + }); + return newMap; + }); + + return this.#resource.reload(); + } + + /** + * Creates a ResourceRef-like object for specific params. + * Automatically registers the params for batched fetching and cleans up on component destruction. + */ + resource( + params: Signal | TParams, + ): BatchingResourceRef { + const destroyRef = inject(DestroyRef); + const paramsSignal = isSignal(params) ? params : signal(params); + + // Track the last registered params + let lastRegisteredParams: TParams | null = null; + + // Use effect to handle params registration + effect(() => { + const currentParams = paramsSignal(); + + // Register new params if changed + if (currentParams !== lastRegisteredParams) { + // Remove old params if exists + if (lastRegisteredParams !== null) { + this.#removeKeys([lastRegisteredParams]); + } + + // Add new params + this.#addKeys([currentParams]); + lastRegisteredParams = currentParams; + } + }); + + // Cleanup when component is destroyed + destroyRef.onDestroy(() => { + if (lastRegisteredParams !== null) { + this.#removeKeys([lastRegisteredParams]); + } + }); + + // Helper to get current cache key + const getCacheKey = () => this.getCacheKey(paramsSignal()); + + // Create item-specific computed signals + const value = computed(() => { + const cacheKey = getCacheKey(); + return this.#cachedResults().get(cacheKey); + }); + + const hasValue = computed(() => { + return this.#cachedResults().has(getCacheKey()); + }); + + const isLoading = computed(() => { + const cacheKey = getCacheKey(); + const statusMap = this.#itemStatuses(); + const itemStatus = statusMap.get(cacheKey); + + // Return loading status from item-specific status map + return itemStatus?.isLoading ?? false; + }); + + const error = computed(() => { + const statusMap = this.#itemStatuses(); + return statusMap.get(getCacheKey())?.error; + }); + + const status = computed(() => { + if (error()) return 'error' as ResourceStatus; + if (isLoading()) return 'loading' as ResourceStatus; + if (hasValue()) return 'resolved' as ResourceStatus; + return 'idle' as ResourceStatus; + }); + + const reload = () => { + this.reloadKeys([paramsSignal()]); + }; + + return { + value, + hasValue, + isLoading, + error, + status, + reload, + }; + } +} diff --git a/libs/common/data-access/src/lib/resources/index.ts b/libs/common/data-access/src/lib/resources/index.ts new file mode 100644 index 000000000..244921bfb --- /dev/null +++ b/libs/common/data-access/src/lib/resources/index.ts @@ -0,0 +1,4 @@ +export { + BatchingResource, + type BatchingResourceRef, +} from './batching-resource.base'; diff --git a/libs/remission/data-access/src/lib/resources/stock.resource.ts b/libs/remission/data-access/src/lib/resources/stock.resource.ts index d732081a1..1f3cb1225 100644 --- a/libs/remission/data-access/src/lib/resources/stock.resource.ts +++ b/libs/remission/data-access/src/lib/resources/stock.resource.ts @@ -1,20 +1,101 @@ -import { Injectable, inject, resource, signal } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; +import { BatchingResource } from '@isa/common/data-access'; import { RemissionStockService } from '../services'; import { FetchStockInStock } from '../schemas'; +import { StockInfo } from '../models'; +/** + * Smart batching resource for stock information. + * Collects item params from multiple components, waits for a batching window, + * then makes a single API request with all items to minimize network calls. + * + * @example + * // In a component + * const stockInfoResource = inject(StockInfoResource); + * const stockInfo = stockInfoResource.resource({ itemId: 123, stockId: 456 }); + * + * // Access the stock info + * const inStock = computed(() => stockInfo.value()?.inStock ?? 0); + */ @Injectable({ providedIn: 'root' }) -export class StockResource { +export class StockInfoResource extends BatchingResource< + { itemId: number; stockId?: number }, + FetchStockInStock, + StockInfo +> { #stockService = inject(RemissionStockService); - #params = signal({ itemIds: [], stockId: undefined }); - - params(params: Partial) { - this.#params.update((current) => ({ ...current, ...params })); + constructor() { + super(250); // batchWindowMs } - readonly resource = resource({ - params: () => this.#params(), - loader: async ({ params, abortSignal }) => - this.#stockService.fetchStockInfos(params, abortSignal), - }); + /** + * Fetch stock information for multiple items. + */ + protected fetchFn( + params: FetchStockInStock, + signal: AbortSignal, + ): Promise { + return this.#stockService.fetchStockInfos(params, signal); + } + + /** + * Build API request params from list of item params. + */ + protected buildParams( + paramsList: { itemId: number; stockId?: number }[], + ): FetchStockInStock { + return { + itemIds: paramsList.map((p) => p.itemId), + stockId: paramsList[0]?.stockId, + }; + } + + /** + * Extract params from result for cache matching. + */ + protected getKeyFromResult( + stock: StockInfo, + ): { itemId: number; stockId?: number } | undefined { + return stock.itemId !== undefined ? { itemId: stock.itemId } : undefined; + } + + /** + * Generate cache key from params. + */ + protected getCacheKey(params: { itemId: number; stockId?: number }): string { + return `${params.stockId ?? 'default'}-${params.itemId}`; + } + + /** + * Extract params from resource params for status tracking. + * Required by BatchingResource base class. + */ + protected extractKeysFromParams( + params: FetchStockInStock, + ): { itemId: number; stockId?: number }[] { + return params.itemIds.map((itemId) => ({ + itemId, + stockId: params.stockId, + })); + } + + /** + * Reloads specific items by removing them from cache. + * Convenience method that accepts item IDs instead of full params. + * + * @param itemIds - Array of item IDs to reload + * @returns Promise that resolves when reload completes + * + * @example + * stockInfoResource.reloadItems([123, 456, 789]); + */ + reloadItems(itemIds: number[]) { + // Convert itemIds to params (stockId will be undefined, matching all stockIds) + const paramsToReload = itemIds.flatMap((itemId) => [ + { itemId, stockId: undefined }, + { itemId }, // stockId undefined variant + ]); + return this.reloadKeys(paramsToReload); + } } diff --git a/libs/remission/feature/remission-list/src/lib/remission-list-item/remission-list-item.component.ts b/libs/remission/feature/remission-list/src/lib/remission-list-item/remission-list-item.component.ts index ea001973c..5c9c5b2f9 100644 --- a/libs/remission/feature/remission-list/src/lib/remission-list-item/remission-list-item.component.ts +++ b/libs/remission/feature/remission-list/src/lib/remission-list-item/remission-list-item.component.ts @@ -76,7 +76,7 @@ export class RemissionListItemComponent { * Signal providing the current breakpoint state. * Used to determine layout orientation and visibility of action buttons. */ - desktopBreakpoint = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]); + desktopBreakpoint = breakpoint([Breakpoint.DesktopL, Breakpoint.DesktopXL]); /** * Signal providing the current remission list type (Abteilung or Pflicht). diff --git a/libs/remission/shared/search-item-to-remit-dialog/src/lib/search-item-to-remit.component.ts b/libs/remission/shared/search-item-to-remit-dialog/src/lib/search-item-to-remit.component.ts index 052c8e02d..114dc7320 100644 --- a/libs/remission/shared/search-item-to-remit-dialog/src/lib/search-item-to-remit.component.ts +++ b/libs/remission/shared/search-item-to-remit-dialog/src/lib/search-item-to-remit.component.ts @@ -1,56 +1,56 @@ -import { - ChangeDetectionStrategy, - Component, - inject, - input, - computed, -} from '@angular/core'; -import { Item } from '@isa/catalogue/data-access'; -import { ProductInfoComponent } from '@isa/remission/shared/product'; -import { TextButtonComponent } from '@isa/ui/buttons'; -import { Breakpoint, breakpoint } from '@isa/ui/layout'; -import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component'; -import { injectDialog } from '@isa/ui/dialog'; -import { SelectRemiQuantityAndReasonDialogComponent } from './select-remi-quantity-and-reason-dialog.component'; -import { firstValueFrom } from 'rxjs'; - -@Component({ - selector: 'remi-search-item-to-remit', - templateUrl: './search-item-to-remit.component.html', - styleUrls: ['./search-item-to-remit.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ProductInfoComponent, TextButtonComponent], -}) -export class SearchItemToRemitComponent { - host = inject(SearchItemToRemitDialogComponent); - quantityAndReasonDialog = injectDialog( - SelectRemiQuantityAndReasonDialogComponent, - ); - - item = input.required(); - inStock = input.required(); - - desktopBreakpoint = breakpoint([Breakpoint.DekstopL, Breakpoint.DekstopXL]); - - productInfoOrientation = computed(() => { - return this.desktopBreakpoint() ? 'vertical' : 'horizontal'; - }); - - async openQuantityAndReasonDialog() { - if (this.item()) { - const dialogRef = this.quantityAndReasonDialog({ - title: 'Dieser Artikel steht nicht auf der Remi Liste', - data: { - item: this.item(), - inStock: this.inStock(), - }, - width: '36rem', - }); - const dialogResult = await firstValueFrom(dialogRef.closed); - - if (dialogResult) { - this.host.close(dialogResult); - } - } - } -} +import { + ChangeDetectionStrategy, + Component, + inject, + input, + computed, +} from '@angular/core'; +import { Item } from '@isa/catalogue/data-access'; +import { ProductInfoComponent } from '@isa/remission/shared/product'; +import { TextButtonComponent } from '@isa/ui/buttons'; +import { Breakpoint, breakpoint } from '@isa/ui/layout'; +import { SearchItemToRemitDialogComponent } from './search-item-to-remit-dialog.component'; +import { injectDialog } from '@isa/ui/dialog'; +import { SelectRemiQuantityAndReasonDialogComponent } from './select-remi-quantity-and-reason-dialog.component'; +import { firstValueFrom } from 'rxjs'; + +@Component({ + selector: 'remi-search-item-to-remit', + templateUrl: './search-item-to-remit.component.html', + styleUrls: ['./search-item-to-remit.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ProductInfoComponent, TextButtonComponent], +}) +export class SearchItemToRemitComponent { + host = inject(SearchItemToRemitDialogComponent); + quantityAndReasonDialog = injectDialog( + SelectRemiQuantityAndReasonDialogComponent, + ); + + item = input.required(); + inStock = input.required(); + + desktopBreakpoint = breakpoint([Breakpoint.DesktopL, Breakpoint.DesktopXL]); + + productInfoOrientation = computed(() => { + return this.desktopBreakpoint() ? 'vertical' : 'horizontal'; + }); + + async openQuantityAndReasonDialog() { + if (this.item()) { + const dialogRef = this.quantityAndReasonDialog({ + title: 'Dieser Artikel steht nicht auf der Remi Liste', + data: { + item: this.item(), + inStock: this.inStock(), + }, + width: '36rem', + }); + const dialogResult = await firstValueFrom(dialogRef.closed); + + if (dialogResult) { + this.host.close(dialogResult); + } + } + } +} diff --git a/libs/ui/layout/src/lib/breakpoint.ts b/libs/ui/layout/src/lib/breakpoint.ts index 3e321a1c7..110f9ba56 100644 --- a/libs/ui/layout/src/lib/breakpoint.ts +++ b/libs/ui/layout/src/lib/breakpoint.ts @@ -1,87 +1,87 @@ -import { inject, isSignal, Signal } from '@angular/core'; -import { BreakpointObserver } from '@angular/cdk/layout'; -import { exhaustMap, isObservable, map, Observable, of, startWith } from 'rxjs'; -import { toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { coerceArray } from '@angular/cdk/coercion'; - -/** - * Enum-like object defining various breakpoints for responsive design. - */ -export const Breakpoint = { - Tablet: 'tablet', - Desktop: 'desktop', - DekstopL: 'dekstop-l', - DekstopXL: 'dekstop-xl', -} as const; - -/** - * Type representing the possible values of the Breakpoint object. - */ -export type Breakpoint = (typeof Breakpoint)[keyof typeof Breakpoint]; - -/** - * Mapping of Breakpoint values to their corresponding CSS media query selectors. - */ -const BreakpointSelector = { - [Breakpoint.Tablet]: '(max-width: 1279px)', - [Breakpoint.Desktop]: '(min-width: 1280px) and (max-width: 1439px)', - [Breakpoint.DekstopL]: '(min-width: 1440px) and (max-width: 1919px)', - [Breakpoint.DekstopXL]: '(min-width: 1920px)', -}; - -/** - * Observes viewport breakpoints and returns an Observable that emits whether the specified breakpoints match. - * - * This function accepts a Breakpoint, an array of Breakpoints, a Signal, or an Observable thereof and returns - * an Observable that emits a boolean indicating if the given breakpoints match the current viewport. - * - * @param breakpoints - A Breakpoint, array of Breakpoints, Signal, or Observable representing breakpoints. - * @returns An Observable that emits a boolean indicating if the specified breakpoints match. - */ -export function breakpoint$( - breakpoints: - | (Breakpoint | Breakpoint[]) - | Signal - | Observable, -): Observable { - const bpObserver = inject(BreakpointObserver); - - const breakpoints$ = isObservable(breakpoints) - ? breakpoints - : isSignal(breakpoints) - ? toObservable(breakpoints) - : of(breakpoints); - - const breakpointSelectors$ = breakpoints$.pipe( - map((bp) => coerceArray(bp).map((b) => BreakpointSelector[b])), - ); - - const match$ = breakpointSelectors$.pipe( - exhaustMap((selectors) => - bpObserver.observe(selectors).pipe( - map((result) => result.matches), - startWith(bpObserver.isMatched(selectors)), - ), - ), - ); - return match$; -} - -/** - * Converts the breakpoint$ Observable into a Signal to provide reactive breakpoint matching. - * - * This function wraps the breakpoint$ function, converting its Observable result into a Signal, - * which can be used with Angular's reactive system. The Signal returns a boolean indicating whether - * the specified breakpoints currently match or undefined if not yet determined. - * - * @param breakpoints - A Breakpoint, array of Breakpoints, Signal, or Observable representing breakpoints. - * @returns A Signal indicating the match state of the breakpoints. - */ -export function breakpoint( - breakpoints: - | (Breakpoint | Breakpoint[]) - | Signal - | Observable, -): Signal { - return toSignal(breakpoint$(breakpoints)); -} +import { inject, isSignal, Signal } from '@angular/core'; +import { BreakpointObserver } from '@angular/cdk/layout'; +import { exhaustMap, isObservable, map, Observable, of, startWith } from 'rxjs'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { coerceArray } from '@angular/cdk/coercion'; + +/** + * Enum-like object defining various breakpoints for responsive design. + */ +export const Breakpoint = { + Tablet: 'tablet', + Desktop: 'desktop', + DesktopL: 'desktop-l', + DesktopXL: 'desktop-xl', +} as const; + +/** + * Type representing the possible values of the Breakpoint object. + */ +export type Breakpoint = (typeof Breakpoint)[keyof typeof Breakpoint]; + +/** + * Mapping of Breakpoint values to their corresponding CSS media query selectors. + */ +const BreakpointSelector = { + [Breakpoint.Tablet]: '(max-width: 1279px)', + [Breakpoint.Desktop]: '(min-width: 1280px) and (max-width: 1439px)', + [Breakpoint.DesktopL]: '(min-width: 1440px) and (max-width: 1919px)', + [Breakpoint.DesktopXL]: '(min-width: 1920px)', +}; + +/** + * Observes viewport breakpoints and returns an Observable that emits whether the specified breakpoints match. + * + * This function accepts a Breakpoint, an array of Breakpoints, a Signal, or an Observable thereof and returns + * an Observable that emits a boolean indicating if the given breakpoints match the current viewport. + * + * @param breakpoints - A Breakpoint, array of Breakpoints, Signal, or Observable representing breakpoints. + * @returns An Observable that emits a boolean indicating if the specified breakpoints match. + */ +export function breakpoint$( + breakpoints: + | (Breakpoint | Breakpoint[]) + | Signal + | Observable, +): Observable { + const bpObserver = inject(BreakpointObserver); + + const breakpoints$ = isObservable(breakpoints) + ? breakpoints + : isSignal(breakpoints) + ? toObservable(breakpoints) + : of(breakpoints); + + const breakpointSelectors$ = breakpoints$.pipe( + map((bp) => coerceArray(bp).map((b) => BreakpointSelector[b])), + ); + + const match$ = breakpointSelectors$.pipe( + exhaustMap((selectors) => + bpObserver.observe(selectors).pipe( + map((result) => result.matches), + startWith(bpObserver.isMatched(selectors)), + ), + ), + ); + return match$; +} + +/** + * Converts the breakpoint$ Observable into a Signal to provide reactive breakpoint matching. + * + * This function wraps the breakpoint$ function, converting its Observable result into a Signal, + * which can be used with Angular's reactive system. The Signal returns a boolean indicating whether + * the specified breakpoints currently match or undefined if not yet determined. + * + * @param breakpoints - A Breakpoint, array of Breakpoints, Signal, or Observable representing breakpoints. + * @returns A Signal indicating the match state of the breakpoints. + */ +export function breakpoint( + breakpoints: + | (Breakpoint | Breakpoint[]) + | Signal + | Observable, +): Signal { + return toSignal(breakpoint$(breakpoints)); +}