Skip to content

Commit 7f89825

Browse files
authored
feat(in-app-analytics): New rule hit table + Filters bar + data fetching (#1150)
* feat: implement new filters * feat: add simple loading state * new i18n namespace * feat(API): add rule_hit_table query definition * refactor: move analytics routes * feat: add Decisions CSV export
1 parent 614b0f8 commit 7f89825

24 files changed

Lines changed: 1113 additions & 327 deletions

File tree

packages/app-builder/src/components/Analytics/Decisions.tsx

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import { Spinner } from '@app-builder/components/Spinner';
12
import { useResizeObserver } from '@app-builder/hooks/useResizeObserver';
23
import {
4+
type DecisionOutcomesAbsolute,
35
DecisionOutcomesPerPeriod,
46
type DecisionsFilter,
7+
type Outcome,
58
outcomeColors,
69
type RangeId,
710
} from '@app-builder/models/analytics';
@@ -31,9 +34,10 @@ export type DecisionsPerOutcome = {
3134
interface DecisionsProps {
3235
data: DecisionOutcomesPerPeriod | null;
3336
scenarioVersions: { version: number; createdAt: string }[];
37+
isLoading?: boolean;
3438
}
3539

36-
export function Decisions({ data, scenarioVersions }: DecisionsProps) {
40+
export function Decisions({ data, scenarioVersions, isLoading = false }: DecisionsProps) {
3741
const { t } = useTranslation();
3842
const language = useFormatLanguage();
3943

@@ -174,20 +178,71 @@ export function Decisions({ data, scenarioVersions }: DecisionsProps) {
174178
return currentDataGroup?.gridXValues;
175179
};
176180

181+
const handleExportCsv = () => {
182+
if (!currentDataGroup) return;
183+
const rows = currentDataGroup.data.absolute;
184+
if (!rows.length) return;
185+
186+
const selectedOutcomes: Outcome[] = Array.from(decisions.entries())
187+
.filter(([, value]) => value)
188+
.map(([key]) => key);
189+
190+
const headers = ['date', 'rangeId', ...selectedOutcomes, ['total']];
191+
192+
const lines = rows.map((row) => {
193+
const base = [row.date, row.rangeId];
194+
type OutcomeValues = Pick<DecisionsPerOutcome, Outcome>;
195+
const outcomeValues = selectedOutcomes.map((k) => {
196+
const v = (row as OutcomeValues)[k];
197+
return String(v);
198+
});
199+
const maybeTotal = [String((row as DecisionOutcomesAbsolute).total ?? 0)];
200+
return [...base, ...outcomeValues, ...maybeTotal].join(',');
201+
});
202+
203+
const csv = [headers.join(','), ...lines].join('\n');
204+
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8,' });
205+
const url = URL.createObjectURL(blob);
206+
const a = document.createElement('a');
207+
a.href = url;
208+
a.download = `decisions_${groupDate}_${percentage ? 'percentage' : 'absolute'}.csv`;
209+
document.body.appendChild(a);
210+
a.click();
211+
a.remove();
212+
URL.revokeObjectURL(url);
213+
};
214+
177215
return (
178216
<div>
179217
<div className="flex items-center justify-between">
180-
<h2 className="text-l font-semibold">{t('analytics:decisions.title')}</h2>
181-
<ButtonV2 variant="secondary" className="flex items-center gap-v2-sm" disabled={true}>
218+
<h2 className="text-h2 font-semibold">{t('analytics:decisions.title')}</h2>
219+
<ButtonV2
220+
variant="secondary"
221+
className="flex items-center gap-v2-sm"
222+
disabled={
223+
isLoading ||
224+
!currentDataGroup ||
225+
(percentage
226+
? (currentDataGroup.data.ratio?.length ?? 0) === 0
227+
: (currentDataGroup.data.absolute?.length ?? 0) === 0)
228+
}
229+
onClick={handleExportCsv}
230+
>
182231
<Icon icon="download" className="size-4" />
183232
{t('analytics:decisions.export.button')}
184233
</ButtonV2>
185234
</div>
186235

187236
<div
188237
ref={divRef}
189-
className="bg-white border border-grey-90 rounded-lg p-v2-md shadow-sm mt-v2-sm"
238+
aria-busy={isLoading}
239+
className="bg-white border border-grey-90 rounded-lg p-v2-md shadow-sm mt-v2-sm relative"
190240
>
241+
{isLoading ? (
242+
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-grey-98/80 hover:bg-grey-95/80">
243+
<Spinner className="size-6" />
244+
</div>
245+
) : null}
191246
<div className="flex w-full h-[500px] flex-col items-start gap-v2-md">
192247
<div className="flex items-center justify-between w-full">
193248
<div className="flex items-center gap-v2-sm">

packages/app-builder/src/components/Analytics/Filters.tsx

Lines changed: 0 additions & 127 deletions
This file was deleted.
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { Spinner } from '@app-builder/components/Spinner';
2+
import { type RuleHitTableResponse } from '@app-builder/models/analytics/rule-hit';
3+
import { formatNumber, useFormatLanguage } from '@app-builder/utils/format';
4+
import { createColumnHelper, getCoreRowModel } from '@tanstack/react-table';
5+
import { useMemo, useState } from 'react';
6+
import { useTranslation } from 'react-i18next';
7+
import { Table, useTable } from 'ui-design-system';
8+
9+
export function RulesHit({
10+
data,
11+
isLoading,
12+
}: {
13+
data: RuleHitTableResponse[];
14+
isLoading: boolean;
15+
}) {
16+
const { t } = useTranslation(['analytics']);
17+
const language = useFormatLanguage();
18+
const [expanded, setExpanded] = useState(false);
19+
20+
const visibleData = useMemo(() => (expanded ? data : data.slice(0, 5)), [expanded, data]);
21+
22+
const columnHelper = createColumnHelper<RuleHitTableResponse>();
23+
const toPercent = (value: number) =>
24+
formatNumber(value > 1 ? value / 100 : value, {
25+
language,
26+
style: 'percent',
27+
maximumFractionDigits: 1,
28+
});
29+
30+
const columns = useMemo(
31+
() => [
32+
columnHelper.accessor((row) => row.ruleName, {
33+
id: 'rule',
34+
header: t('analytics:ruleshit.columns.rule'),
35+
size: 220,
36+
cell: ({ getValue }) => <span className="line-clamp-1">{getValue()}</span>,
37+
}),
38+
columnHelper.accessor((row) => row.hitCount, {
39+
id: 'hitCount',
40+
header: t('analytics:ruleshit.columns.hit_count'),
41+
size: 100,
42+
cell: ({ getValue }) => <span>{formatNumber(getValue(), { language })}</span>,
43+
}),
44+
columnHelper.accessor((row) => row.hitRatio, {
45+
id: 'hitRatio',
46+
header: t('analytics:ruleshit.columns.hit_ratio'),
47+
size: 120,
48+
cell: ({ getValue }) => <span>{toPercent(getValue())}</span>,
49+
}),
50+
columnHelper.accessor((row) => row.pivotCount, {
51+
id: 'pivotCount',
52+
header: t('analytics:ruleshit.columns.pivot_count'),
53+
size: 140,
54+
cell: ({ getValue }) => <span>{formatNumber(getValue(), { language })}</span>,
55+
}),
56+
columnHelper.accessor((row) => row.pivotRatio, {
57+
id: 'pivotRatio',
58+
header: t('analytics:ruleshit.columns.pivot_ratio'),
59+
size: 160,
60+
cell: ({ getValue }) => <span>{toPercent(getValue())}</span>,
61+
}),
62+
],
63+
[columnHelper, language, t],
64+
);
65+
66+
const { table, getBodyProps, rows, getContainerProps } = useTable({
67+
data: visibleData,
68+
columns,
69+
columnResizeMode: 'onChange',
70+
getCoreRowModel: getCoreRowModel(),
71+
enableSorting: false,
72+
});
73+
return (
74+
<div className="mt-v2-xl">
75+
<div className="flex items-center justify-between">
76+
<h2 className="text-h2 font-semibold">{t('analytics:ruleshit.title')}</h2>
77+
</div>
78+
79+
<div
80+
aria-busy={isLoading}
81+
className="bg-white border border-grey-90 rounded-lg p-v2-md shadow-sm mt-v2-sm relative"
82+
>
83+
{isLoading ? (
84+
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-lg bg-grey-98/80 hover:bg-grey-95/80">
85+
<Spinner className="size-6" />
86+
</div>
87+
) : null}
88+
<div className="flex w-full flex-col items-start gap-v2-md">
89+
<Table.Container {...getContainerProps()} className="bg-grey-100 w-full">
90+
<Table.Header headerGroups={table.getHeaderGroups()} />
91+
<Table.Body {...getBodyProps()}>
92+
{rows.map((row) => (
93+
<Table.Row key={row.id} row={row} />
94+
))}
95+
{!expanded && data.length > 5 ? (
96+
<tr
97+
className="even:bg-grey-98 h-12 hover:bg-purple-98 cursor-pointer"
98+
onClick={() => setExpanded(true)}
99+
>
100+
<td
101+
className="text-s w-full truncate px-4 font-medium text-purple-65"
102+
colSpan={table.getHeaderGroups()[0]?.headers.length ?? 5}
103+
>
104+
{t('analytics:ruleshit.see_more.label')}
105+
</td>
106+
</tr>
107+
) : null}
108+
</Table.Body>
109+
</Table.Container>
110+
</div>
111+
</div>
112+
</div>
113+
);
114+
}

0 commit comments

Comments
 (0)