Skip to content

Commit 6084549

Browse files
authored
feat(WIP): new analytics page (#1126)
* feat(WIP): new analytics page * feat: add scenario selector * feat: add date range filter * feat: add support of a second date range * feat: handle compare range colors * refactor: split chart component * fix: bars granularity * feat: correct value groups * feat: parallelize requests * refactor: shift business logique to backend BFF * fix: ticks display
1 parent 42e4d60 commit 6084549

15 files changed

Lines changed: 1351 additions & 105 deletions

File tree

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import {
2+
DecisionOutcomesPerPeriod,
3+
type DecisionsFilter,
4+
type RangeId,
5+
} from '@app-builder/models/analytics';
6+
import { type ComputedDatum, ResponsiveBar } from '@nivo/bar';
7+
import { useMemo, useState } from 'react';
8+
import { ButtonV2 } from 'ui-design-system';
9+
10+
export type DateRange = {
11+
start: string;
12+
end: string;
13+
};
14+
export type DecisionsPerOutcome = {
15+
rangeId: RangeId;
16+
date: string;
17+
approve: number;
18+
decline: number;
19+
review: number;
20+
blockAndReview: number;
21+
total?: number;
22+
};
23+
24+
interface DecisionsProps {
25+
data: DecisionOutcomesPerPeriod | null;
26+
decisions: DecisionsFilter;
27+
setDecisions: (decisions: DecisionsFilter) => void;
28+
scenarioVersions: { version: number; createdAt: string }[];
29+
}
30+
31+
export function Decisions({ data, decisions, setDecisions, scenarioVersions }: DecisionsProps) {
32+
// Decision filter default values
33+
const defaultDecisions: DecisionsFilter = new Map([
34+
['decline', true],
35+
['blockAndReview', true],
36+
['review', true],
37+
['approve', false],
38+
]);
39+
const [decisionsState, setDecisionsState] = useState<DecisionsFilter>(defaultDecisions);
40+
const [percentage, setPercentage] = useState(true);
41+
const [groupDate, setGroupDate] = useState<'daily' | 'weekly' | 'monthly'>('weekly');
42+
43+
const currentDataGroup = useMemo(() => data?.[groupDate], [data, groupDate]);
44+
45+
// for future use
46+
47+
// interface ScenarioVersionsXMarker {
48+
// axis: 'x';
49+
// legend: string;
50+
// legendOrientation: 'horizontal';
51+
// value: string;
52+
// lineStyle: {
53+
// stroke: string;
54+
// strokeWidth: number;
55+
// };
56+
// }
57+
58+
// const getVersionsXValues = (
59+
// values: DecisionsPerOutcome[],
60+
// scenarioVersions: { version: number; createdAt: string }[],
61+
// ): ScenarioVersionsXMarker[] => {
62+
// // Find the closest date for each scenario version and override the scenario version creation date with the date of the closest date
63+
// return scenarioVersions
64+
// .map(({ version, createdAt }) => {
65+
// const closestDate = values.find((value) => value.date >= createdAt);
66+
// return !closestDate
67+
// ? undefined
68+
// : {
69+
// axis: 'x' as const,
70+
// value: closestDate.date,
71+
// lineStyle: {
72+
// stroke: 'rgba(0, 0, 0, .35)',
73+
// strokeWidth: 2,
74+
// },
75+
// legend: `v${version}`,
76+
// legendOrientation: 'horizontal' as const,
77+
// };
78+
// })
79+
// .filter((v) => v !== undefined);
80+
// };
81+
82+
const getBarColors = (d: ComputedDatum<{ rangeId: RangeId }>) => {
83+
const id = String(d.id) as 'approve' | 'decline' | 'review' | 'blockAndReview';
84+
const colors = {
85+
approve: '#10b981',
86+
decline: '#ef4444',
87+
review: '#f59e0b',
88+
blockAndReview: '#ffab73',
89+
};
90+
return colors[id] ?? '#9ca3af';
91+
};
92+
93+
if (!data) {
94+
return null;
95+
}
96+
return (
97+
<div className="flex w-3xl h-96 p-4 flex-col items-start gap-2">
98+
<div className="flex items-center gap-2">
99+
<span className="text-s">Count:</span>
100+
<div className="flex gap-1">
101+
<ButtonV2
102+
variant="secondary"
103+
onClick={() => {
104+
setPercentage(true);
105+
setDecisionsState(
106+
new Map([
107+
['decline', true],
108+
['blockAndReview', true],
109+
['review', true],
110+
['approve', true],
111+
]),
112+
);
113+
}}
114+
className={percentage ? 'bg-purple-98 border-purple-65 text-purple-65' : ''}
115+
>
116+
%
117+
</ButtonV2>
118+
<ButtonV2
119+
variant="secondary"
120+
onClick={() => setPercentage(false)}
121+
className={!percentage ? 'bg-purple-98 border-purple-65 text-purple-65' : ''}
122+
>
123+
#
124+
</ButtonV2>
125+
</div>
126+
</div>
127+
<div className="relative flex-1 w-full">
128+
<ResponsiveBar
129+
data={
130+
percentage
131+
? (currentDataGroup?.data.ratio ?? [])
132+
: (currentDataGroup?.data.absolute ?? [])
133+
}
134+
indexBy="date"
135+
enableLabel={false}
136+
keys={
137+
percentage
138+
? ['decline', 'blockAndReview', 'review', 'approve']
139+
: Array.from(decisionsState)
140+
.filter(([_, value]) => value)
141+
.map(([key]) => key)
142+
}
143+
padding={0.5}
144+
margin={{ top: 80, right: 130, bottom: 50, left: 60 }}
145+
colors={getBarColors}
146+
defs={[
147+
{
148+
id: 'compareOpacity',
149+
type: 'linearGradient',
150+
colors: [
151+
{ offset: 0, color: 'inherit', opacity: 0.5 },
152+
{ offset: 100, color: 'inherit', opacity: 0.5 },
153+
],
154+
},
155+
]}
156+
fill={[
157+
{
158+
match: (n) => n.data.data['rangeId'] === 'compare',
159+
id: 'compareOpacity',
160+
},
161+
]}
162+
axisLeft={{ legend: 'outcome (indexBy)', legendOffset: -70 }}
163+
axisBottom={{
164+
tickValues: currentDataGroup?.gridXValues,
165+
format: (value: string) => {
166+
// Convert the ISO string to a Date object and format it
167+
const date = new Date(value);
168+
return date.toLocaleDateString('fr-FR', {
169+
month: 'short',
170+
day: 'numeric',
171+
});
172+
},
173+
}}
174+
// markers={currentDataGroup?.scenarioVersionsXMarkers}
175+
/>
176+
<div className="relative bottom-2 right-2 flex gap-2">
177+
<ButtonV2
178+
variant="secondary"
179+
mode="normal"
180+
onClick={() => setGroupDate('daily')}
181+
className={groupDate === 'daily' ? 'bg-purple-98 border-purple-65 text-purple-65' : ''}
182+
>
183+
Day
184+
</ButtonV2>
185+
<ButtonV2
186+
variant="secondary"
187+
mode="normal"
188+
onClick={() => setGroupDate('weekly')}
189+
className={groupDate === 'weekly' ? 'bg-purple-98 border-purple-65 text-purple-65' : ''}
190+
>
191+
Week
192+
</ButtonV2>
193+
<ButtonV2
194+
variant="secondary"
195+
mode="normal"
196+
onClick={() => setGroupDate('monthly')}
197+
className={
198+
groupDate === 'monthly' ? 'bg-purple-98 border-purple-65 text-purple-65' : ''
199+
}
200+
>
201+
Month
202+
</ButtonV2>
203+
</div>
204+
</div>
205+
</div>
206+
);
207+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { useDateRangeSearchParams } from '@app-builder/hooks/useDateRangeSearchParams';
2+
import { type Scenario } from '@app-builder/models/scenario';
3+
import { formatDateTimeWithoutPresets, useFormatLanguage } from '@app-builder/utils/format';
4+
import { useTranslation } from 'react-i18next';
5+
import { Button, MenuCommand, Separator } from 'ui-design-system';
6+
import { Icon } from 'ui-icons';
7+
import { DateRangeFilter } from '../Filters';
8+
import { FilterItem, FilterPopover } from '../Filters/FilterPopover';
9+
10+
export function Filters({
11+
selectedScenarioId,
12+
scenarios,
13+
onSelectedScenarioIdChange,
14+
}: {
15+
selectedScenarioId: string;
16+
scenarios: Scenario[];
17+
onSelectedScenarioIdChange: (scenarioId: string) => void;
18+
}) {
19+
const { t } = useTranslation(['decisions', 'common']);
20+
const language = useFormatLanguage();
21+
const selectedScenario = scenarios.find((scenario) => scenario.id === selectedScenarioId);
22+
const { range, compareRange, setDateRangeFilter, setCompareRange } = useDateRangeSearchParams();
23+
24+
return (
25+
<div className="flex flex-row gap-2 p-2">
26+
<MenuCommand.Menu>
27+
<MenuCommand.Trigger>
28+
<Button variant="primary" size="medium">
29+
<MenuCommand.Arrow />
30+
<span className="text-xs">{selectedScenario?.name}</span>
31+
</Button>
32+
</MenuCommand.Trigger>
33+
<MenuCommand.Content sameWidth sideOffset={4} align="start" className="min-w-24">
34+
<MenuCommand.Combobox autoFocus />
35+
<MenuCommand.List className="p-1">
36+
{scenarios.map((scenario) => (
37+
<MenuCommand.Item
38+
key={scenario.id}
39+
value={scenario.id}
40+
onSelect={() => onSelectedScenarioIdChange(scenario.id)}
41+
>
42+
<span className="text-xs">{scenario.name}</span>
43+
</MenuCommand.Item>
44+
))}
45+
</MenuCommand.List>
46+
</MenuCommand.Content>
47+
</MenuCommand.Menu>
48+
49+
<FilterPopover.Root key={'dateRange'}>
50+
<FilterItem.Root>
51+
<FilterItem.Trigger>
52+
<Icon icon="calendar-month" className="size-5" />
53+
<span className="text-s font-semibold first-letter:capitalize">
54+
Period from{' '}
55+
{formatDateTimeWithoutPresets(range.start, {
56+
language,
57+
dateStyle: 'short',
58+
})}{' '}
59+
to{' '}
60+
{formatDateTimeWithoutPresets(range.end, {
61+
language,
62+
dateStyle: 'short',
63+
})}
64+
</span>
65+
</FilterItem.Trigger>
66+
<FilterItem.Clear onClick={() => setDateRangeFilter(null)} />
67+
</FilterItem.Root>
68+
<FilterPopover.Content>
69+
<DateRangeFilter.Root
70+
dateRangeFilter={{ type: 'static', startDate: range.start, endDate: range.end }}
71+
setDateRangeFilter={setDateRangeFilter}
72+
className="grid"
73+
>
74+
<DateRangeFilter.FromNowPicker title={t('decisions:filters.date_range.title')} />
75+
<Separator className="bg-grey-90" decorative orientation="vertical" />
76+
<DateRangeFilter.Calendar />
77+
<Separator className="bg-grey-90 col-span-3" decorative orientation="horizontal" />
78+
<DateRangeFilter.Summary className="col-span-3 row-span-1" />
79+
</DateRangeFilter.Root>
80+
</FilterPopover.Content>
81+
</FilterPopover.Root>
82+
<FilterPopover.Root key={'dateRangeCompare'}>
83+
<FilterItem.Root>
84+
<FilterItem.Trigger>
85+
<Icon icon="calendar-month" className="size-5" />
86+
{compareRange ? (
87+
<span className="text-s font-semibold first-letter:capitalize">
88+
Period from{' '}
89+
{formatDateTimeWithoutPresets(compareRange.start, {
90+
language,
91+
dateStyle: 'short',
92+
})}{' '}
93+
to{' '}
94+
{formatDateTimeWithoutPresets(compareRange.end, {
95+
language,
96+
dateStyle: 'short',
97+
})}
98+
</span>
99+
) : (
100+
<span className="text-s font-semibold first-letter:capitalize">
101+
Add compare period
102+
</span>
103+
)}
104+
</FilterItem.Trigger>
105+
<FilterItem.Clear onClick={() => setCompareRange(null)} />
106+
</FilterItem.Root>
107+
<FilterPopover.Content>
108+
<DateRangeFilter.Root
109+
dateRangeFilter={{
110+
type: 'static',
111+
startDate: compareRange?.start ?? '',
112+
endDate: compareRange?.end ?? '',
113+
}}
114+
setDateRangeFilter={setCompareRange}
115+
className="grid"
116+
>
117+
<DateRangeFilter.FromNowPicker title={t('decisions:filters.date_range.title')} />
118+
<Separator className="bg-grey-90" decorative orientation="vertical" />
119+
<DateRangeFilter.Calendar />
120+
<Separator className="bg-grey-90 col-span-3" decorative orientation="horizontal" />
121+
<DateRangeFilter.Summary className="col-span-3 row-span-1" />
122+
</DateRangeFilter.Root>
123+
</FilterPopover.Content>
124+
</FilterPopover.Root>
125+
</div>
126+
);
127+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { DecisionsFilter, type Outcome, outcomeColors } from '@app-builder/models/analytics';
2+
import { Icon } from 'ui-icons';
3+
4+
export function OutcomeFilter({
5+
decisions,
6+
onChange,
7+
}: {
8+
decisions: DecisionsFilter;
9+
onChange: (decisions: DecisionsFilter) => void;
10+
}) {
11+
const handleToggle = (key: Outcome) => {
12+
const newDecisions = new Map(decisions);
13+
newDecisions.set(key, !decisions.get(key));
14+
onChange(newDecisions);
15+
};
16+
17+
const FilterItem = ({
18+
label,
19+
outcome,
20+
checked,
21+
}: {
22+
label: string;
23+
outcome: Outcome;
24+
checked: boolean;
25+
}) => (
26+
<div
27+
className={`flex items-center gap-2 cursor-pointer flex-1 min-w-0 ${outcome === 'blockAndReview' ? 'min-w-40' : ''} ${!checked ? 'text-grey-50' : ''}`}
28+
onClick={() => handleToggle(outcome)}
29+
>
30+
<button
31+
className={
32+
'w-4 h-4 border border-grey-90 rounded-sm flex items-center justify-center hover:bg-grey-50 ' +
33+
(checked ? outcomeColors[outcome] : 'bg-transparent') +
34+
' ' +
35+
outcomeColors[outcome]
36+
}
37+
style={{ backgroundColor: outcomeColors[outcome] }}
38+
></button>
39+
<div className="flex items-center flex-1 whitespace-nowrap min-w-0">
40+
<span className="text-xs">{label}</span>
41+
<div className="w-4 h-4 flex items-center justify-center flex-shrink-0 ml-4">
42+
{!checked && <Icon icon="eye-slash" className="w-4 h-4 text-gray-400" />}
43+
</div>
44+
</div>
45+
</div>
46+
);
47+
48+
return (
49+
<div className={`flex flex-row gap-6`}>
50+
<FilterItem label="Approve" outcome="approve" checked={decisions.get('approve') ?? false} />
51+
<FilterItem label="Review" outcome="review" checked={decisions.get('review') ?? false} />
52+
<FilterItem
53+
label="Block and Review"
54+
outcome="blockAndReview"
55+
checked={decisions.get('blockAndReview') ?? false}
56+
/>
57+
<FilterItem label="Decline" outcome="decline" checked={decisions.get('decline') ?? false} />
58+
</div>
59+
);
60+
}

0 commit comments

Comments
 (0)