Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
89d282b
feat(web,analytics): /analytics route shell — date-range toolbar, tru…
jakubretajczykBD Aug 14, 2026
c370c6c
fix(web,analytics): address PR #2115 review — style-guide entries, mo…
jakubretajczykBD Aug 17, 2026
5abbac1
fix(web,analytics): address PR #2115 re-review — failing tests, hones…
jakubretajczykBD Aug 18, 2026
63ac38d
fix(web,analytics): render the real earliestOrderDate now that #2083 …
jakubretajczykBD Aug 18, 2026
8dc855b
Merge branch '1986-analytics-page-shell' into 1990-sales-analytics-tr…
jakubretajczykBD Aug 18, 2026
e811488
feat(web,analytics): sales KPI strip + by-channel table (#1990)
jakubretajczykBD Aug 18, 2026
c81b527
fix(web,analytics): type the pending-promise mocks in KPI strip/chann…
jakubretajczykBD Aug 18, 2026
8bcc4ed
Merge remote-tracking branch 'origin/1987-sales-channel-aggregates' i…
jakubretajczykBD Aug 19, 2026
c76306f
Merge remote-tracking branch 'origin/1987-sales-channel-aggregates' i…
jakubretajczykBD Aug 19, 2026
22a7d26
fix(web,analytics): align KPI strip/by-channel table with the real #1…
jakubretajczykBD Aug 19, 2026
81f635b
docs(analytics): implementation plan for /analytics needs-attention s…
jakubretajczykBD Aug 14, 2026
8d8c963
feat(web,analytics): /analytics needs-attention section (#1989)
jakubretajczykBD Aug 14, 2026
9a1ebef
fix(web,analytics): match needs-attention section to the #2003 mockup
jakubretajczykBD Aug 17, 2026
71a8f70
fix(web,analytics): add missing earliestOrderDate to a needs-attentio…
jakubretajczykBD Aug 18, 2026
6d99c8f
Merge remote-tracking branch 'origin/1989-needs-attention-plan' into …
jakubretajczykBD Aug 20, 2026
d427f39
Merge remote-tracking branch 'origin/1987-sales-channel-aggregates' i…
jakubretajczykBD Aug 20, 2026
0cd5b48
Merge remote-tracking branch 'origin/1986-analytics-page-shell-plan' …
jakubretajczykBD Aug 20, 2026
e39bdc9
Merge remote-tracking branch 'origin/1990-sales-analytics-trend-plan'…
jakubretajczykBD Aug 20, 2026
55c9c2c
Merge remote-tracking branch 'origin/1986-analytics-page-shell-plan' …
jakubretajczykBD Aug 21, 2026
919e5e4
fix(analytics): address #2120 tech review — sample-vs-total headline …
jakubretajczykBD Aug 21, 2026
34d7e48
fix(analytics): address #1990/PR #2171 tech review — KPI strip UTC bo…
jakubretajczykBD Aug 21, 2026
375dca5
fix(web): remove unused vi import breaking tsc build
jakubretajczykBD Aug 21, 2026
6f2d2c8
fix(analytics): emit currency total for single-contributing-channel g…
jakubretajczykBD Aug 21, 2026
7b1331b
fix(analytics): address #2120 re-review — deep-link connection resolu…
jakubretajczykBD Aug 21, 2026
469d6f6
Merge remote-tracking branch 'origin/1986-analytics-page-shell-plan' …
jakubretajczykBD Aug 21, 2026
98d9502
fix(web): remove unused vi import in sales-analytics.api.test.ts
jakubretajczykBD Aug 21, 2026
55e7a09
fix(analytics): stop rendering a currency-neutral total on the failed…
jakubretajczykBD Aug 21, 2026
27db4a6
fix(analytics): stop rendering a duplicate/colliding unconverted Tota…
jakubretajczykBD Aug 21, 2026
c9dc74f
fix(analytics): stop the coverage deep-link from naming a channel the…
jakubretajczykBD Aug 21, 2026
bbb6278
feat(web,analytics): top products table with per-channel breakdown (#…
jakubretajczykBD Aug 24, 2026
2a33346
fix(web,analytics): make GapMark's caveat reachable without a mouse
jakubretajczykBD Aug 24, 2026
fec84e0
Merge remote-tracking branch 'origin/1989-needs-attention-plan' into …
jakubretajczykBD Aug 24, 2026
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
16 changes: 16 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,15 @@ module.exports = {
'**/allegro/components/**',
'**/allegro/lib/**',
'**/allegro/types/**',
// Added on the #2191 tech-review pass — `features/analytics`
// gained a public barrel (top-products view-model helpers,
// types) that the ChannelCell/ProductSalesTable slice
// consumes cross-feature.
'**/analytics/api/**',
'**/analytics/hooks/**',
'**/analytics/components/**',
'**/analytics/lib/**',
'**/analytics/types/**',
// Added in #1938 review, when `features/demo` reached into
// `../../auth/hooks/`. Enumerated in full like every other
// slug; note that `pages/auth/*` and `app/api/api-client.ts`
Expand Down Expand Up @@ -429,6 +438,13 @@ module.exports = {
'**/allegro/components/**',
'**/allegro/lib/**',
'**/allegro/types/**',
// Added on the #2191 tech-review pass, mirroring the
// features/ group above — new slug goes into BOTH groups.
'**/analytics/api/**',
'**/analytics/hooks/**',
'**/analytics/components/**',
'**/analytics/lib/**',
'**/analytics/types/**',
// #1938 review — `features/auth` gained a barrel; a new slug
// goes into BOTH pattern groups per
// docs/frontend-architecture.md § Feature Public Surface,
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/app/api/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,15 @@ import {
createAiProviderSettingsApi,
type AiProviderSettingsApi,
} from '../../features/ai-provider-settings/api/ai-provider-settings.api';
import { createAnalyticsApi, type AnalyticsApi } from '../../features/analytics/api/sales-analytics.api';
import {
createAnalyticsTrustApi,
type AnalyticsTrustApi,
} from '../../features/analytics/api/analytics-trust.api';
import {
createTopProductsApi,
type TopProductsApi,
} from '../../features/analytics/api/top-products.api';
import { createAuthApi, type AuthApi } from '../../features/auth/api/auth.api';
import {
createConnectionsApi,
Expand Down Expand Up @@ -148,6 +153,7 @@ export interface PluginApiNamespaces {}
export interface CoreApiClient {
adapters: AdaptersApi;
aiProviderSettings: AiProviderSettingsApi;
analytics: AnalyticsApi & TopProductsApi;
analyticsTrust: AnalyticsTrustApi;
auth: AuthApi;
connections: ConnectionsApi;
Expand Down Expand Up @@ -366,6 +372,7 @@ export function createApiClient({
const core: CoreApiClient = {
adapters: createAdaptersApi(request),
aiProviderSettings: createAiProviderSettingsApi(request),
analytics: { ...createAnalyticsApi(request), ...createTopProductsApi(request) },
analyticsTrust: createAnalyticsTrustApi(request),
auth: createAuthApi(request),
connections: createConnectionsApi(request),
Expand Down
12 changes: 9 additions & 3 deletions apps/web/src/features/analytics/api/analytics-trust.api.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
/**
* Analytics Trust API Client
*
* Thin API module for the analytics data-trust read. A single GET the
* /analytics page calls before rendering any figure, to disclose the
* limits of the data it reports over (#1982).
* Thin API module for the `/analytics` page's data-trust read (#1982) and
* needs-attention read (#1983/#1989) — both under the `/analytics` resource
* family, kept in one namespace to avoid growing `CoreApiClient` for a
* single extra method.
*
* @module apps/web/src/features/analytics/api
*/
import type { AnalyticsTrustSnapshot } from './analytics-trust.types';
import type { NeedsAttentionSummary } from './needs-attention.types';

export interface AnalyticsTrustApi {
getTrust: () => Promise<AnalyticsTrustSnapshot>;
getNeedsAttention: () => Promise<NeedsAttentionSummary>;
}

interface ApiRequest {
Expand All @@ -22,5 +25,8 @@ export function createAnalyticsTrustApi(request: ApiRequest): AnalyticsTrustApi
getTrust(): Promise<AnalyticsTrustSnapshot> {
return request<AnalyticsTrustSnapshot>('/analytics/trust');
},
getNeedsAttention(): Promise<NeedsAttentionSummary> {
return request<NeedsAttentionSummary>('/analytics/needs-attention');
},
};
}
10 changes: 10 additions & 0 deletions apps/web/src/features/analytics/api/needs-attention.query-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Needs Attention Query Keys
*
* @module apps/web/src/features/analytics/api
*/

export const needsAttentionQueryKeys = {
all: ['needs-attention'] as const,
summary: () => ['needs-attention', 'summary'] as const,
};
47 changes: 47 additions & 0 deletions apps/web/src/features/analytics/api/needs-attention.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/**
* Needs Attention Types
*
* Frontend transport types mirroring the backend `NeedsAttentionResponseDto`
* contract (`GET /analytics/needs-attention`, #1983). Hand-written per the
* FE-001 contract strategy — keep in sync with the backend DTO.
*
* @module apps/web/src/features/analytics/api
*/

export interface CoverageGapItem {
variantId: string;
productId: string;
listedOnConnectionIds: string[];
missingFromConnectionIds: string[];
}

export interface StockAtRiskItem {
variantId: string;
productId: string;
connectionId: string;
masterStock: number;
stockSafetyBuffer: number;
}

/**
* `mixedCurrency: true` means `totalValue` sums orders across more than one
* currency and must not be labeled with a single currency symbol. There is
* no currency field on this contract for the non-mixed case either — the
* consumer renders a currency-neutral number in both cases (#1989 pre-implement
* gate finding; a real per-order currency awaits #2049's reporting-currency
* stamping on `order_records`).
*/
export interface FailedSyncValueSummary {
count: number;
totalValue: number;
mixedCurrency: boolean;
oldestFailedAt: string | null;
}

export interface NeedsAttentionSummary {
coverageGaps: CoverageGapItem[];
coverageGapsTotalCount: number;
stockAtRisk: StockAtRiskItem[];
stockAtRiskTotalCount: number;
failedSyncValue: FailedSyncValueSummary;
}
25 changes: 25 additions & 0 deletions apps/web/src/features/analytics/api/sales-analytics.api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { toExclusiveEndInstant } from './sales-analytics.api';

describe('toExclusiveEndInstant', () => {
const originalTZ = process.env.TZ;

beforeEach(() => {
process.env.TZ = 'America/New_York'; // UTC-4/-5, exercises a negative offset
});

afterEach(() => {
process.env.TZ = originalTZ;
});

it('anchors on UTC midnight of the day after `to`, not local midnight', () => {
// Local midnight for 2026-03-15 in America/New_York is 2026-03-15T04:00:00Z —
// a UTC anchor must not drift onto that local instant.
expect(toExclusiveEndInstant('2026-03-14')).toBe('2026-03-15T00:00:00.000Z');
});

it('rolls over month and year boundaries in UTC', () => {
expect(toExclusiveEndInstant('2026-01-31')).toBe('2026-02-01T00:00:00.000Z');
expect(toExclusiveEndInstant('2025-12-31')).toBe('2026-01-01T00:00:00.000Z');
});
});
54 changes: 54 additions & 0 deletions apps/web/src/features/analytics/api/sales-analytics.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* Sales analytics API client
*
* Thin request module for `GET /analytics/sales` (#1987).
*
* The endpoint treats `to` as an EXCLUSIVE boundary (`[from, to)`,
* `sales-analytics.controller.ts` / `SalesAnalyticsFilters`), while the
* `/analytics` date-range toolbar (`date-range.lib.ts`) hands this module an
* INCLUSIVE `yyyy-mm-dd` end day. Passing the inclusive day straight through
* would parse to midnight UTC of that day and silently drop the entire
* selected last day's figures — `toExclusiveEndInstant` is the one place
* that conversion happens, so it can't be forgotten at a call site.
*
* @module features/analytics/api
*/
import type { SalesAndChannelAnalytics, SalesAnalyticsFilters } from './sales-analytics.types';

export interface AnalyticsApi {
getSales: (filters: SalesAnalyticsFilters) => Promise<SalesAndChannelAnalytics>;
}

interface ApiRequest {
<T>(path: string, init?: RequestInit): Promise<T>;
}

/**
* Converts an inclusive `yyyy-mm-dd` end day into the exclusive ISO instant
* the endpoint expects — UTC midnight of the day AFTER `to`, so the whole of
* `to` itself is included in `[from, to)`. Must stay UTC-anchored: `from` is
* sent as a bare `yyyy-mm-dd` and parsed by the controller as UTC midnight
* (`new Date(...)`), so a local-time anchor here would make the window
* `[UTC midnight, local midnight)` — off by the caller's UTC offset in
* either direction.
*/
export function toExclusiveEndInstant(to: string): string {
const [year, month, day] = to.split('-').map(Number);
return new Date(Date.UTC(year, month - 1, day + 1)).toISOString();
}

function buildQuery(filters: SalesAnalyticsFilters): string {
const params = new URLSearchParams();
params.set('from', filters.from);
params.set('to', toExclusiveEndInstant(filters.to));
if (filters.sourceConnectionId) {
params.set('sourceConnectionId', filters.sourceConnectionId);
}
return params.toString();
}

export function createAnalyticsApi(request: ApiRequest): AnalyticsApi {
return {
getSales: (filters) => request(`/analytics/sales?${buildQuery(filters)}`),
};
}
10 changes: 10 additions & 0 deletions apps/web/src/features/analytics/api/sales-analytics.query-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Sales analytics query keys
*
* @module features/analytics/api
*/
import type { SalesAnalyticsFilters } from './sales-analytics.types';

export const salesAnalyticsQueryKeys = {
sales: (filters: SalesAnalyticsFilters) => ['analytics', 'sales', filters] as const,
};
85 changes: 85 additions & 0 deletions apps/web/src/features/analytics/api/sales-analytics.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* Sales analytics types
*
* Mirrors `apps/api/src/analytics/http/dto/sales-analytics-*.dto.ts` (#1987).
* `DailyTrendPoint` carries only `revenue` and `orderCount` — there is no
* daily series for units, AOV, or cancellations, which is why those figures
* render without a sparkline on the KPI strip (see `analytics-kpi-strip.tsx`).
*
* Currency model (#1987 + #2049/ADR-040 follow-up): there is exactly ONE
* system-wide reporting currency, never a per-channel native currency.
* `revenue`/`averageOrderValue`/`medianOrderValue` sum only orders whose
* `reportingCurrency` stamp has landed — `currency` names which one, and is
* `null` only when nothing in scope has been stamped yet. `unconvertedCount`/
* `unconvertedValue` disclose orders excluded from `revenue` because they
* have no stamp yet (recently ingested, or pre-dating the FX epic);
* `unconvertedValue` is a native-currency sum, informational only, labelled
* by `unconvertedCurrency` — `null` when that set itself spans more than one
* native currency (never assume it matches `currency`). `revenueShare` is
* always a number (`0` when headline revenue is `0`), since every channel's
* `revenue` is expressed in the same one currency.
*
* @module features/analytics/api
*/
export interface DailyTrendPoint {
/** yyyy-mm-dd */
date: string;
revenue: number;
orderCount: number;
}

export interface SalesAnalyticsHeadline {
/** `SUM(reportingTotalAmount)` over stamped, non-cancelled orders — expressed in `currency`. */
revenue: number;
/** The reporting currency `revenue`/`averageOrderValue`/`medianOrderValue` are expressed in. `null` when nothing in range has been stamped yet. */
currency: string | null;
/** Non-cancelled, stamped orders only — the subset `revenue`/`averageOrderValue` are computed from. */
orderCount: number;
averageOrderValue: number;
medianOrderValue: number;
unitsSold: number;
cancelledCount: number;
/** Native-currency sum — may mix currencies; a secondary figure, not gated behind a stamp. */
cancelledValue: number;
/** Non-cancelled orders in range with no reporting-currency stamp yet — not reflected in `revenue`. */
unconvertedCount: number;
/** Native-currency sum for `unconvertedCount` — informational only, may mix currencies. */
unconvertedValue: number;
/** The one native currency `unconvertedValue` is expressed in; `null` when that set mixes currencies (or `unconvertedCount` is `0`). */
unconvertedCurrency: string | null;
trend: DailyTrendPoint[];
}

export interface ChannelSalesAnalytics {
sourceConnectionId: string;
/** Same meaning as {@link SalesAnalyticsHeadline.revenue}, scoped to this channel. */
revenue: number;
/** Same meaning as {@link SalesAnalyticsHeadline.currency}, scoped to this channel — independently nullable. */
currency: string | null;
orderCount: number;
averageOrderValue: number;
unitsSold: number;
cancelledCount: number;
cancelledValue: number;
unconvertedCount: number;
unconvertedValue: number;
unconvertedCurrency: string | null;
/** Share of headline revenue, `0` when headline revenue is `0` — always comparable, since every channel's `revenue` is in the same `currency`. */
revenueShare: number;
trend: DailyTrendPoint[];
/** `false` when this channel's oldest ingested order postdates the requested range start. */
coverageComplete: boolean;
}

export interface SalesAndChannelAnalytics {
headline: SalesAnalyticsHeadline;
channels: ChannelSalesAnalytics[];
}

export interface SalesAnalyticsFilters {
/** Range start, inclusive, `yyyy-mm-dd` (matches the toolbar's `date-range.lib.ts`). */
from: string;
/** Range end, inclusive, `yyyy-mm-dd` — converted to an exclusive instant before the request is sent, see `sales-analytics.api.ts`. */
to: string;
sourceConnectionId?: string;
}
40 changes: 40 additions & 0 deletions apps/web/src/features/analytics/api/top-products.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* Top products API client
*
* Thin request module for `GET /analytics/top-products` (#1988). Reuses
* `toExclusiveEndInstant` from `sales-analytics.api.ts` for the same
* inclusive-day-to-exclusive-instant conversion the toolbar's `to` value
* needs — see that module's header for why the conversion lives in one
* place.
*
* @module features/analytics/api
*/
import { toExclusiveEndInstant } from './sales-analytics.api';
import type { TopProductsFilters, TopProductsResult } from './top-products.types';

export interface TopProductsApi {
getTopProducts: (filters: TopProductsFilters) => Promise<TopProductsResult>;
}

interface ApiRequest {
<T>(path: string, init?: RequestInit): Promise<T>;
}

function buildQuery(filters: TopProductsFilters): string {
const params = new URLSearchParams();
params.set('from', filters.from);
params.set('to', toExclusiveEndInstant(filters.to));
if (filters.sourceConnectionId) {
params.set('sourceConnectionId', filters.sourceConnectionId);
}
params.set('sortBy', filters.sortBy);
params.set('limit', String(filters.limit));
params.set('offset', String(filters.offset));
return params.toString();
}

export function createTopProductsApi(request: ApiRequest): TopProductsApi {
return {
getTopProducts: (filters) => request(`/analytics/top-products?${buildQuery(filters)}`),
};
}
10 changes: 10 additions & 0 deletions apps/web/src/features/analytics/api/top-products.query-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Top products query keys
*
* @module features/analytics/api
*/
import type { TopProductsFilters } from './top-products.types';

export const topProductsQueryKeys = {
topProducts: (filters: TopProductsFilters) => ['analytics', 'top-products', filters] as const,
};
Loading
Loading