Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions apps/web/src/app/api/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ import {
createAiProviderSettingsApi,
type AiProviderSettingsApi,
} from '../../features/ai-provider-settings/api/ai-provider-settings.api';
import {
createAnalyticsTrustApi,
type AnalyticsTrustApi,
} from '../../features/analytics/api/analytics-trust.api';
import { createAuthApi, type AuthApi } from '../../features/auth/api/auth.api';
import {
createConnectionsApi,
Expand Down Expand Up @@ -106,6 +110,7 @@ export interface PluginApiNamespaces {}
export interface CoreApiClient {
adapters: AdaptersApi;
aiProviderSettings: AiProviderSettingsApi;
analyticsTrust: AnalyticsTrustApi;
auth: AuthApi;
connections: ConnectionsApi;
content: ContentApi;
Expand Down Expand Up @@ -261,6 +266,7 @@ export function createApiClient({
const core: CoreApiClient = {
adapters: createAdaptersApi(request),
aiProviderSettings: createAiProviderSettingsApi(request),
analyticsTrust: createAnalyticsTrustApi(request),
auth: createAuthApi(request),
connections: createConnectionsApi(request),
content: createContentApi(request),
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/app/nav-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const BASE_NAV_GROUPS: readonly NavRegistryGroup[] = [
label: 'Operations',
items: [
{ to: '/', label: 'Dashboard', end: true },
{ to: '/analytics', label: 'Analytics' },
{ to: '/orders', label: 'Orders', countKey: 'orders' },
{ to: '/products', label: 'Products' },
{ to: '/customers', label: 'Customers', countKey: 'customers' },
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/app/routes/analytics.route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { RouteObject } from 'react-router-dom';
import type { RouteCrumbHandle } from '../nav-registry.types';

export const analyticsRoute: RouteObject = {
path: 'analytics',
handle: { crumb: { group: 'Operations', title: 'Analytics' } } satisfies RouteCrumbHandle,
lazy: async () => {
const { AnalyticsPage } = await import('../../pages/analytics/analytics-page');
return { Component: AnalyticsPage };
},
};
2 changes: 2 additions & 0 deletions apps/web/src/app/routes/root.route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { RouteObject } from 'react-router-dom';
import { plugins } from '../../plugins';
import { AuthenticatedAppLayout } from '../layouts/authenticated-app-layout';
import { adaptersRoute } from './adapters.route';
import { analyticsRoute } from './analytics.route';
import { connectionDetailRoute } from './connection-detail.route';
import { connectionCategoryMappingsRoute } from './connection-category-mappings.route';
import { connectionMappingsRoute } from './connection-mappings.route';
Expand Down Expand Up @@ -51,6 +52,7 @@ import { webhookDeliveriesRoute } from './webhook-deliveries.route';
*/
export const coreChildren: RouteObject[] = [
dashboardRoute,
analyticsRoute,
ordersRoute,
productsRoute,
cursorsRoute,
Expand Down
9 changes: 5 additions & 4 deletions apps/web/src/app/routes/route-lazy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@ const lazyRoutes = collectLazyRoutes([
* route reverted to eager `element:` form, which is exactly the regression
* the parameterized test below is meant to catch.
*
* Today's breakdown (49 total):
* - 34 authenticated children (under `coreChildren`, counting per-children-node
* Today's breakdown (51 total):
* - 35 authenticated children (under `coreChildren`, counting per-children-node
* because grouped routes like orders/customers expose multiple
* lazy nodes — includes `/dev/ui` design-system page (#775), `/shipments` (#770),
* `/users` user-management page (#1125), and `/invoices/:invoiceId` detail (#1240);
* `/users` user-management page (#1125), `/invoices/:invoiceId` detail (#1240),
* and `/analytics` (#1986);
* the former `/inventory/:id` detail route was removed (#1305/#1609) once
* `product-detail-page.tsx` subsumed per-item stock detail, and the
* `/inventory` list route was removed (#1720) when the products cockpit
Expand All @@ -69,7 +70,7 @@ const lazyRoutes = collectLazyRoutes([
* - login (first-paint optimization — see `login.route.tsx`)
* - prompt-templates-legacy-redirects (inline `<Navigate>` element)
*/
const EXPECTED_LAZY_ROUTE_COUNT = 50;
const EXPECTED_LAZY_ROUTE_COUNT = 51;

describe('route lazy contract', () => {
it(`the registered route tree contains exactly ${EXPECTED_LAZY_ROUTE_COUNT} lazy routes`, () => {
Expand Down
26 changes: 26 additions & 0 deletions apps/web/src/features/analytics/api/analytics-trust.api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* 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).
*
* @module apps/web/src/features/analytics/api
*/
import type { AnalyticsTrustSnapshot } from './analytics-trust.types';

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

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

export function createAnalyticsTrustApi(request: ApiRequest): AnalyticsTrustApi {
return {
getTrust(): Promise<AnalyticsTrustSnapshot> {
return request<AnalyticsTrustSnapshot>('/analytics/trust');
},
};
}
10 changes: 10 additions & 0 deletions apps/web/src/features/analytics/api/analytics-trust.query-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Analytics Trust Query Keys
*
* @module apps/web/src/features/analytics/api
*/

export const analyticsTrustQueryKeys = {
all: ['analytics-trust'] as const,
snapshot: () => ['analytics-trust', 'snapshot'] as const,
};
37 changes: 37 additions & 0 deletions apps/web/src/features/analytics/api/analytics-trust.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* Analytics Trust Feature Types
*
* Frontend transport types for the analytics data-trust read. Mirrors the
* backend AnalyticsTrustResponseDto / ConnectionIngestionTrustResponseDto
* contracts (#1982). All date fields are ISO 8601 strings.
*
* @module apps/web/src/features/analytics/api
*/
import type { ConnectionStatus } from '../../connections';

export type ConnectionIngestionStatus =
| 'never-ingested'
| 'fresh'
| 'stalled'
| 'disconnected'
| 'unknown';

export interface ConnectionIngestionTrust {
connectionId: string;
connectionName: string;
platformType: string;
connectionStatus: ConnectionStatus;
status: ConnectionIngestionStatus;
lastPollAt: string | null;
lastOrderIngestedAt: string | null;
connectionCreatedAt: string;
earliestOrderDate: string | null;
expectedIntervalMs: number | null;
staleAfterMs: number | null;
}

export interface AnalyticsTrustSnapshot {
generatedAt: string;
worstStatus: ConnectionIngestionStatus;
connections: ConnectionIngestionTrust[];
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it, vi } from 'vitest';
import { AnalyticsDateRangeToolbar } from './analytics-date-range-toolbar';

describe('AnalyticsDateRangeToolbar', () => {
it('should call onApply immediately with the correct dates when a preset is clicked', async () => {
const user = userEvent.setup();
const onApply = vi.fn();
render(<AnalyticsDateRangeToolbar from="2026-07-16" to="2026-08-14" onApply={onApply} />);

await user.click(screen.getByRole('radio', { name: '7d' }));

expect(onApply).toHaveBeenCalledTimes(1);
const [calledFrom, calledTo] = onApply.mock.calls[0] as [string, string];
expect(calledTo <= calledFrom).toBe(false);
});

it('should not call onApply when Custom is clicked', async () => {
const user = userEvent.setup();
const onApply = vi.fn();
render(<AnalyticsDateRangeToolbar from="2026-07-16" to="2026-08-14" onApply={onApply} />);

await user.click(screen.getByRole('radio', { name: 'Custom' }));

expect(onApply).not.toHaveBeenCalled();
});

it('should enable Apply only after a date field is edited to a different, complete, valid range', async () => {
const user = userEvent.setup();
const onApply = vi.fn();
render(<AnalyticsDateRangeToolbar from="2026-07-16" to="2026-08-14" onApply={onApply} />);

expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();

const fromInput = screen.getByLabelText('Order date from');
await user.clear(fromInput);
await user.type(fromInput, '2026-03-01');

expect(screen.getByRole('button', { name: 'Apply' })).toBeEnabled();
expect(onApply).not.toHaveBeenCalled();

await user.click(screen.getByRole('button', { name: 'Apply' }));
expect(onApply).toHaveBeenCalledWith('2026-03-01', '2026-08-14');
});

it('should keep Apply disabled when the draft range is invalid (From after To)', async () => {
const user = userEvent.setup();
render(<AnalyticsDateRangeToolbar from="2026-07-16" to="2026-08-14" onApply={vi.fn()} />);

const toInput = screen.getByLabelText('Order date to');
await user.clear(toInput);
await user.type(toInput, '2026-01-01');

expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
});

it('should reset the draft when the committed from/to props change', () => {
const { rerender } = render(
<AnalyticsDateRangeToolbar from="2026-07-16" to="2026-08-14" onApply={vi.fn()} />
);

rerender(<AnalyticsDateRangeToolbar from="2026-01-01" to="2026-01-07" onApply={vi.fn()} />);

expect(screen.getByLabelText('Order date from')).toHaveValue('2026-01-01');
expect(screen.getByRole('button', { name: 'Apply' })).toBeDisabled();
});

it('should render the "Order date" disclaimer chip', () => {
render(<AnalyticsDateRangeToolbar from="2026-07-16" to="2026-08-14" onApply={vi.fn()} />);

expect(
screen.getByText((_content, element) => element?.textContent === 'Order date †')
).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Analytics Date Range Toolbar
*
* Presets (7d/30d/90d/Custom) + From/To date fields + a draft-buffered
* Apply action. Presets commit immediately; a typed range stays a local
* draft until Apply is clicked — see Decision 1 in
* docs/plans/implementation-plan-analytics-page-shell.md.
*
* @module apps/web/src/features/analytics/components
*/
import { useEffect, useRef, useState, type ReactElement } from 'react';
import { Button, SegmentedControl } from '../../../shared/ui';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../shared/ui/tooltip';
import {
computePresetRange,
derivePreset,
type DateRangeHighlight,
type DateRangePreset,
} from '../lib/date-range.lib';

interface AnalyticsDateRangeToolbarProps {
from: string;
to: string;
onApply: (from: string, to: string) => void;
}

const PRESET_OPTIONS: readonly { value: DateRangeHighlight; label: string }[] = [
{ value: '7d', label: '7d' },
{ value: '30d', label: '30d' },
{ value: '90d', label: '90d' },
{ value: 'custom', label: 'Custom' },
];

// Operator-facing: no schema jargon (no "placedAt", no "column"). The
// underlying reason is that `placedAt` isn't yet a filterable read-model
// column — tracked for #1990 — but that's a developer fact, not one an
// operator can act on, so it stays out of the rendered copy/aria-label.
const ORDER_DATE_CAVEAT = "This range doesn't filter results yet — coming soon";

export function AnalyticsDateRangeToolbar({
from,
to,
onApply,
}: AnalyticsDateRangeToolbarProps): ReactElement {
const today = useRef(new Date()).current;
const [draftFrom, setDraftFrom] = useState(from);
const [draftTo, setDraftTo] = useState(to);
const [forcedCustom, setForcedCustom] = useState(false);
const fromInputRef = useRef<HTMLInputElement>(null);

// Reload / commit both land here: the draft resets to whatever is now
// the committed range, and the forced-custom override clears so the
// highlight re-derives from the (possibly new) committed dates.
useEffect(() => {
setDraftFrom(from);
setDraftTo(to);
setForcedCustom(false);
}, [from, to]);

const highlight: DateRangeHighlight = forcedCustom ? 'custom' : derivePreset(from, to, today);

const canApply =
draftFrom.length > 0 &&
draftTo.length > 0 &&
draftFrom <= draftTo &&
(draftFrom !== from || draftTo !== to);

function handleSegmentChange(value: DateRangeHighlight): void {
if (value === 'custom') {
setForcedCustom(true);
fromInputRef.current?.focus();
return;
}
const range = computePresetRange(value as DateRangePreset, today);
onApply(range.from, range.to);
}

function handleApply(): void {
if (!canApply) return;
onApply(draftFrom, draftTo);
}

return (
<TooltipProvider>
<div className="toolbar analytics-toolbar">
<div className="toolbar__group">
<SegmentedControl
aria-label="Date range"
options={PRESET_OPTIONS}
value={highlight}
onChange={handleSegmentChange}
/>
<label className="analytics-toolbar__field">
<span className="analytics-toolbar__label">From</span>
<input
ref={fromInputRef}
type="date"
className="control"
aria-label="Order date from"
value={draftFrom}
onChange={(event) => {
setForcedCustom(true);
setDraftFrom(event.target.value);
}}
/>
</label>
<label className="analytics-toolbar__field">
<span className="analytics-toolbar__label">To</span>
<input
type="date"
className="control"
aria-label="Order date to"
value={draftTo}
onChange={(event) => {
setForcedCustom(true);
setDraftTo(event.target.value);
}}
/>
</label>
<Button type="button" tone="secondary" disabled={!canApply} onClick={handleApply}>
Apply
</Button>
{/* Static disclaimer, not an interactive control — a plain chip
markup rather than the interactive Chip primitive, which would
render a toggle button that toggles nothing (aria-pressed with
no onClick). */}
<span className="chip chip--info" aria-label={`Order date. ${ORDER_DATE_CAVEAT}`}>
Order date{' '}
<Tooltip>
<TooltipTrigger asChild>
<span className="analytics-gap-mark" tabIndex={0}>
</span>
</TooltipTrigger>
<TooltipContent>{ORDER_DATE_CAVEAT}</TooltipContent>
</Tooltip>
</span>
</div>
</div>
</TooltipProvider>
);
}
Loading