diff --git a/packages/app-builder/src/components/Analytics/Decisions.tsx b/packages/app-builder/src/components/Analytics/Decisions.tsx index 2f58efae46..f640031383 100644 --- a/packages/app-builder/src/components/Analytics/Decisions.tsx +++ b/packages/app-builder/src/components/Analytics/Decisions.tsx @@ -1,7 +1,10 @@ +import { Spinner } from '@app-builder/components/Spinner'; import { useResizeObserver } from '@app-builder/hooks/useResizeObserver'; import { + type DecisionOutcomesAbsolute, DecisionOutcomesPerPeriod, type DecisionsFilter, + type Outcome, outcomeColors, type RangeId, } from '@app-builder/models/analytics'; @@ -31,9 +34,10 @@ export type DecisionsPerOutcome = { interface DecisionsProps { data: DecisionOutcomesPerPeriod | null; scenarioVersions: { version: number; createdAt: string }[]; + isLoading?: boolean; } -export function Decisions({ data, scenarioVersions }: DecisionsProps) { +export function Decisions({ data, scenarioVersions, isLoading = false }: DecisionsProps) { const { t } = useTranslation(); const language = useFormatLanguage(); @@ -174,11 +178,56 @@ export function Decisions({ data, scenarioVersions }: DecisionsProps) { return currentDataGroup?.gridXValues; }; + const handleExportCsv = () => { + if (!currentDataGroup) return; + const rows = currentDataGroup.data.absolute; + if (!rows.length) return; + + const selectedOutcomes: Outcome[] = Array.from(decisions.entries()) + .filter(([, value]) => value) + .map(([key]) => key); + + const headers = ['date', 'rangeId', ...selectedOutcomes, ['total']]; + + const lines = rows.map((row) => { + const base = [row.date, row.rangeId]; + type OutcomeValues = Pick; + const outcomeValues = selectedOutcomes.map((k) => { + const v = (row as OutcomeValues)[k]; + return String(v); + }); + const maybeTotal = [String((row as DecisionOutcomesAbsolute).total ?? 0)]; + return [...base, ...outcomeValues, ...maybeTotal].join(','); + }); + + const csv = [headers.join(','), ...lines].join('\n'); + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8,' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `decisions_${groupDate}_${percentage ? 'percentage' : 'absolute'}.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + }; + return (
-

{t('analytics:decisions.title')}

- +

{t('analytics:decisions.title')}

+ {t('analytics:decisions.export.button')} @@ -186,8 +235,14 @@ export function Decisions({ data, scenarioVersions }: DecisionsProps) {
+ {isLoading ? ( +
+ +
+ ) : null}
diff --git a/packages/app-builder/src/components/Analytics/Filters.tsx b/packages/app-builder/src/components/Analytics/Filters.tsx deleted file mode 100644 index 89077dec19..0000000000 --- a/packages/app-builder/src/components/Analytics/Filters.tsx +++ /dev/null @@ -1,127 +0,0 @@ -import { useDateRangeSearchParams } from '@app-builder/hooks/useDateRangeSearchParams'; -import { type Scenario } from '@app-builder/models/scenario'; -import { formatDateTimeWithoutPresets, useFormatLanguage } from '@app-builder/utils/format'; -import { useTranslation } from 'react-i18next'; -import { Button, MenuCommand, Separator } from 'ui-design-system'; -import { Icon } from 'ui-icons'; -import { DateRangeFilter } from '../Filters'; -import { FilterItem, FilterPopover } from '../Filters/FilterPopover'; - -export function Filters({ - selectedScenarioId, - scenarios, - onSelectedScenarioIdChange, -}: { - selectedScenarioId: string; - scenarios: Scenario[]; - onSelectedScenarioIdChange: (scenarioId: string) => void; -}) { - const { t } = useTranslation(['decisions', 'common', 'analytics']); - const language = useFormatLanguage(); - const selectedScenario = scenarios.find((scenario) => scenario.id === selectedScenarioId); - const { range, compareRange, setDateRangeFilter, setCompareRange } = useDateRangeSearchParams(); - - return ( -
- - - - - - - - {scenarios.map((scenario) => ( - onSelectedScenarioIdChange(scenario.id)} - > - {scenario.name} - - ))} - - - - - - - - - - Period from{' '} - {formatDateTimeWithoutPresets(range.start, { - language, - dateStyle: 'short', - })}{' '} - to{' '} - {formatDateTimeWithoutPresets(range.end, { - language, - dateStyle: 'short', - })} - - - setDateRangeFilter(null)} /> - - - - - - - - - - - - - - - - {compareRange ? ( - - Period from{' '} - {formatDateTimeWithoutPresets(compareRange.start, { - language, - dateStyle: 'short', - })}{' '} - to{' '} - {formatDateTimeWithoutPresets(compareRange.end, { - language, - dateStyle: 'short', - })} - - ) : ( - - {t('analytics:filters.add_compare_period')} - - )} - - setCompareRange(null)} /> - - - - - - - - - - - -
- ); -} diff --git a/packages/app-builder/src/components/Analytics/RulesHit.tsx b/packages/app-builder/src/components/Analytics/RulesHit.tsx new file mode 100644 index 0000000000..c49494d90d --- /dev/null +++ b/packages/app-builder/src/components/Analytics/RulesHit.tsx @@ -0,0 +1,114 @@ +import { Spinner } from '@app-builder/components/Spinner'; +import { type RuleHitTableResponse } from '@app-builder/models/analytics/rule-hit'; +import { formatNumber, useFormatLanguage } from '@app-builder/utils/format'; +import { createColumnHelper, getCoreRowModel } from '@tanstack/react-table'; +import { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Table, useTable } from 'ui-design-system'; + +export function RulesHit({ + data, + isLoading, +}: { + data: RuleHitTableResponse[]; + isLoading: boolean; +}) { + const { t } = useTranslation(['analytics']); + const language = useFormatLanguage(); + const [expanded, setExpanded] = useState(false); + + const visibleData = useMemo(() => (expanded ? data : data.slice(0, 5)), [expanded, data]); + + const columnHelper = createColumnHelper(); + const toPercent = (value: number) => + formatNumber(value > 1 ? value / 100 : value, { + language, + style: 'percent', + maximumFractionDigits: 1, + }); + + const columns = useMemo( + () => [ + columnHelper.accessor((row) => row.ruleName, { + id: 'rule', + header: t('analytics:ruleshit.columns.rule'), + size: 220, + cell: ({ getValue }) => {getValue()}, + }), + columnHelper.accessor((row) => row.hitCount, { + id: 'hitCount', + header: t('analytics:ruleshit.columns.hit_count'), + size: 100, + cell: ({ getValue }) => {formatNumber(getValue(), { language })}, + }), + columnHelper.accessor((row) => row.hitRatio, { + id: 'hitRatio', + header: t('analytics:ruleshit.columns.hit_ratio'), + size: 120, + cell: ({ getValue }) => {toPercent(getValue())}, + }), + columnHelper.accessor((row) => row.pivotCount, { + id: 'pivotCount', + header: t('analytics:ruleshit.columns.pivot_count'), + size: 140, + cell: ({ getValue }) => {formatNumber(getValue(), { language })}, + }), + columnHelper.accessor((row) => row.pivotRatio, { + id: 'pivotRatio', + header: t('analytics:ruleshit.columns.pivot_ratio'), + size: 160, + cell: ({ getValue }) => {toPercent(getValue())}, + }), + ], + [columnHelper, language, t], + ); + + const { table, getBodyProps, rows, getContainerProps } = useTable({ + data: visibleData, + columns, + columnResizeMode: 'onChange', + getCoreRowModel: getCoreRowModel(), + enableSorting: false, + }); + return ( +
+
+

{t('analytics:ruleshit.title')}

+
+ +
+ {isLoading ? ( +
+ +
+ ) : null} +
+ + + + {rows.map((row) => ( + + ))} + {!expanded && data.length > 5 ? ( + setExpanded(true)} + > + + {t('analytics:ruleshit.see_more.label')} + + + ) : null} + + +
+
+
+ ); +} diff --git a/packages/app-builder/src/hooks/useDateRangeSearchParams.ts b/packages/app-builder/src/hooks/useDateRangeSearchParams.ts index 26595c77df..50a9a270e5 100644 --- a/packages/app-builder/src/hooks/useDateRangeSearchParams.ts +++ b/packages/app-builder/src/hooks/useDateRangeSearchParams.ts @@ -1,6 +1,6 @@ import { useSearchParams } from '@remix-run/react'; import { subDays, subMonths } from 'date-fns'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useState } from 'react'; type StaticDateRange = { type: 'static'; startDate: string; endDate: string }; type DynamicDateRange = { type: 'dynamic'; fromNow: string }; @@ -17,39 +17,58 @@ function getDefaultRange(): { start: string; end: string } { return { start: toIso(start), end: toIso(end) }; } +function parseQ(qValue: string | null): { range: IsoRange; compareRange: IsoRange | null } { + if (qValue) { + try { + const obj = JSON.parse(atob(qValue)) as { + range?: { start?: string; end?: string } | null; + compareRange?: { start?: string; end?: string } | null; + }; + if (obj?.range?.start && obj?.range?.end) { + return { + range: { start: obj.range.start, end: obj.range.end }, + compareRange: + obj.compareRange?.start && obj.compareRange?.end + ? { start: obj.compareRange.start, end: obj.compareRange.end } + : null, + }; + } + } catch { + // ignore malformed q + } + } + const defaults = getDefaultRange(); + return { range: { start: defaults.start, end: defaults.end }, compareRange: null }; +} + export function useDateRangeSearchParams() { const [searchParams, setSearchParams] = useSearchParams(); - const parseQ = useCallback( - (qValue: string | null): { range: IsoRange; compareRange: IsoRange | null } => { - if (qValue) { - try { - const obj = JSON.parse(atob(qValue)) as { - range?: { start?: string; end?: string } | null; - compareRange?: { start?: string; end?: string } | null; - }; - if (obj?.range?.start && obj?.range?.end) { - return { - range: { start: obj.range.start, end: obj.range.end }, - compareRange: - obj.compareRange?.start && obj.compareRange?.end - ? { start: obj.compareRange.start, end: obj.compareRange.end } - : null, - }; - } - } catch { - // ignore malformed q - } + const [dateRangeState, setDateRangeState] = useState(() => { + // Initialize state with parsed values + const qValue = searchParams.get('q'); + return parseQ(qValue); + }); + + // Parse and update state only when the parsed values actually change + useEffect(() => { + const qValue = searchParams.get('q'); + const parsed = parseQ(qValue); + + // Only update state if values actually changed + setDateRangeState((current) => { + if ( + current.range.start !== parsed.range.start || + current.range.end !== parsed.range.end || + current.compareRange?.start !== parsed.compareRange?.start || + current.compareRange?.end !== parsed.compareRange?.end + ) { + return parsed; } - const defaults = getDefaultRange(); - return { range: { start: defaults.start, end: defaults.end }, compareRange: null }; - }, - [], - ); + return current; + }); + }, [searchParams, parseQ]); - const { range, compareRange } = useMemo( - () => parseQ(searchParams.get('q')), - [parseQ, searchParams], - ); + const { range, compareRange } = dateRangeState; const computeDynamicRange = useCallback((fromNow: string): IsoRange => { const now = new Date(); @@ -75,7 +94,7 @@ export function useDateRangeSearchParams() { params.delete('end'); return params; }, - [parseQ], + [], ); const setDateRangeFilter = useCallback( @@ -95,7 +114,7 @@ export function useDateRangeSearchParams() { const dynamicRange = computeDynamicRange(dateRange.fromNow); return writeQ(prev, { range: dynamicRange, compareRange: undefined }); }, - { replace: true }, + { replace: false }, ); }, [computeDynamicRange, setSearchParams, writeQ], @@ -116,7 +135,7 @@ export function useDateRangeSearchParams() { return writeQ(prev, { compareRange: nextCompare }); }, - { replace: true }, + { replace: false }, ); }, [computeDynamicRange, setSearchParams, writeQ], diff --git a/packages/app-builder/src/locales/ar/analytics.json b/packages/app-builder/src/locales/ar/analytics.json index 1bb8b3ea67..2a2aed8799 100644 --- a/packages/app-builder/src/locales/ar/analytics.json +++ b/packages/app-builder/src/locales/ar/analytics.json @@ -4,5 +4,14 @@ "decisions.tooltip.daily": "التاريخ: {{date}}", "decisions.tooltip.monthly": "شهر {{date}}", "decisions.tooltip.weekly": "أسبوع {{date}}
(الأسبوع {{weekNumber}})", - "filters.add_compare_period": "إضافة فترة مقارنة" + "filters.select_comparison_date_range.placeholder": "إضافة فترة للمقارنة", + "filters.select_date_range.placeholder": "اختر فترة", + "filters.select_scenario.placeholder": "اختر السيناريو", + "ruleshit.columns.hit_count": "# مرات التطابق", + "ruleshit.columns.hit_ratio": "% مرات التطابق", + "ruleshit.columns.pivot_count": "# المحاور/المستخدمون المميزون", + "ruleshit.columns.pivot_ratio": "% المحاور/المستخدمون المميزون", + "ruleshit.columns.rule": "القاعدة", + "ruleshit.see_more.label": "عرض المزيد +", + "ruleshit.title": "القرارات بالقاعدة" } diff --git a/packages/app-builder/src/locales/en/analytics.json b/packages/app-builder/src/locales/en/analytics.json index 7c0ffee223..bf03126d04 100644 --- a/packages/app-builder/src/locales/en/analytics.json +++ b/packages/app-builder/src/locales/en/analytics.json @@ -4,5 +4,14 @@ "decisions.tooltip.daily": "date: {{date}}", "decisions.tooltip.monthly": "month of {{date}}", "decisions.tooltip.weekly": "week of {{date}}
(Week {{weekNumber}})", - "filters.add_compare_period": "Add compare period" + "filters.select_comparison_date_range.placeholder": "Add period to compare", + "filters.select_date_range.placeholder": "Select date range", + "filters.select_scenario.placeholder": "Select scenario", + "ruleshit.columns.hit_count": "# hits", + "ruleshit.columns.hit_ratio": "% hits", + "ruleshit.columns.pivot_count": "# distinct pivots/users", + "ruleshit.columns.pivot_ratio": "% distinct pivots/users", + "ruleshit.columns.rule": "Rule", + "ruleshit.see_more.label": "See more +", + "ruleshit.title": "Decisions rules" } diff --git a/packages/app-builder/src/locales/fr/analytics.json b/packages/app-builder/src/locales/fr/analytics.json index c8954412cf..3326e071eb 100644 --- a/packages/app-builder/src/locales/fr/analytics.json +++ b/packages/app-builder/src/locales/fr/analytics.json @@ -4,5 +4,14 @@ "decisions.tooltip.daily": "date: {{date}}", "decisions.tooltip.monthly": "mois de {{date}}", "decisions.tooltip.weekly": "semaine du {{date}}
(Semaine {{weekNumber}})", - "filters.add_compare_period": "Ajouter une période de comparaison" + "filters.select_comparison_date_range.placeholder": "Ajouter une période à comparer", + "filters.select_date_range.placeholder": "Sélectionner une période", + "filters.select_scenario.placeholder": "Sélectionner un scénario", + "ruleshit.columns.hit_count": "# déclenchements", + "ruleshit.columns.hit_ratio": "% déclenchements", + "ruleshit.columns.pivot_count": "# pivots/utilisateurs distincts", + "ruleshit.columns.pivot_ratio": "% pivots/utilisateurs distincts", + "ruleshit.columns.rule": "Règle", + "ruleshit.see_more.label": "Voir plus +", + "ruleshit.title": "Décisions par règle" } diff --git a/packages/app-builder/src/models/analytics/available-filters.ts b/packages/app-builder/src/models/analytics/available-filters.ts new file mode 100644 index 0000000000..63e7dcb7c1 --- /dev/null +++ b/packages/app-builder/src/models/analytics/available-filters.ts @@ -0,0 +1,37 @@ +import { type AvailableFiltersRequestDto, type AvailableFiltersResponseDto } from 'marble-api'; +import * as R from 'remeda'; + +export type AvailableFilterType = 'string' | 'number' | 'boolean'; +export type FilterSource = 'triggerObject'; + +export type AvailableFiltersRequest = { + scenarioId: string; + start: string; + end: string; +}; + +export type AvailableFiltersResponse = { + name: string; + type: AvailableFilterType; + source: FilterSource; +}[]; + +export const adaptAvailableFiltersResponse = ( + response: AvailableFiltersResponseDto, +): AvailableFiltersResponse => { + return response.map((item) => ({ + name: item.name, + type: item.type as AvailableFilterType, + source: R.toCamelCase(item.source) as FilterSource, + })); +}; + +export const transformAvailableFiltersRequest = ( + request: AvailableFiltersRequest, +): AvailableFiltersRequestDto => { + return { + scenario_id: request.scenarioId, + start: request.start, + end: request.end, + }; +}; diff --git a/packages/app-builder/src/models/analytics/decisions-outcomes-perday.ts b/packages/app-builder/src/models/analytics/decisions-outcomes-perday.ts index fa26c280e2..e91f85778e 100644 --- a/packages/app-builder/src/models/analytics/decisions-outcomes-perday.ts +++ b/packages/app-builder/src/models/analytics/decisions-outcomes-perday.ts @@ -9,11 +9,10 @@ import { getMonth, isAfter, isBefore, - startOfDay, startOfMonth, startOfWeek, } from 'date-fns'; -import { DecisionOutcomesPerDayQueryDto, type DecisionOutcomesPerDayResponseDto } from 'marble-api'; +import { type DecisionOutcomesPerDayResponseDto } from 'marble-api'; import z from 'zod'; export type RangeId = 'base' | 'compare'; @@ -69,28 +68,10 @@ export interface DecisionOutcomesPerPeriod { export const triggerFilter = z.object({ field: z.uuidv4(), - op: z.enum(['=', '!=', '>', '>=', '<', '<=']), - values: z.array(z.string()), + op: z.enum(['=', '!=', '>', '>=', '<', '<=', 'in']), + values: z.array(z.union([z.string(), z.number(), z.boolean()])), }); -export const decisionOutcomesPerDayQuery = z.object({ - dateRange: z.object({ - start: z.iso.datetime(), - end: z.iso.datetime(), - }), - compareDateRange: z - .object({ - start: z.iso.datetime(), - end: z.iso.datetime(), - }) - .optional(), - scenarioId: z.uuidv4(), - scenarioVersion: z.number().optional(), - trigger: z.array(triggerFilter), -}); - -export type DecisionOutcomesPerDayQuery = z.infer; - export const mergeDateRanges = ( dateRanges: DecisionOutcomesPerDayResponseDto[][], ): DecisionOutcomesPerDayEntity[] => @@ -162,31 +143,6 @@ export const fillMissingDays = ( }, []); }; -export const transformDecisionOutcomesPerDayQuery = decisionOutcomesPerDayQuery.transform( - (val): DecisionOutcomesPerDayQueryDto[] => { - return [ - { - start: startOfDay(val.dateRange.start).toISOString(), - end: startOfDay(val.dateRange.end).toISOString(), - scenario_id: val.scenarioId, - scenario_versions: val.scenarioVersion ? [val.scenarioVersion] : [], - trigger: val.trigger, - }, - ...(val.compareDateRange - ? [ - { - start: startOfDay(val.compareDateRange.start).toISOString(), - end: startOfDay(val.compareDateRange.end).toISOString(), - scenario_id: val.scenarioId, - scenario_versions: val.scenarioVersion ? [val.scenarioVersion] : [], - trigger: val.trigger, - }, - ] - : []), - ]; - }, -); - const MAX_TICKS = 12; const limitTicks = (data: DecisionOutcomesAbsolute[]): DecisionOutcomesAbsolute[] => data.filter( diff --git a/packages/app-builder/src/models/analytics/index.ts b/packages/app-builder/src/models/analytics/index.ts index adc4a65399..4348e5f027 100644 --- a/packages/app-builder/src/models/analytics/index.ts +++ b/packages/app-builder/src/models/analytics/index.ts @@ -1,3 +1,9 @@ +import { startOfDay } from 'date-fns'; +import { AnalyticsQueryDto } from 'marble-api'; +import z from 'zod'; +import { triggerFilter } from './decisions-outcomes-perday'; + +export * from './available-filters'; export * from './decisions-outcomes-perday'; export * as legacyAnalytics from './legacy-analytics'; @@ -10,3 +16,44 @@ export const outcomeColors: Record = { blockAndReview: '#FFECE6', decline: '#E99B8E', }; + +export const analyticsQuery = z.object({ + dateRange: z.object({ + start: z.iso.datetime(), + end: z.iso.datetime(), + }), + compareDateRange: z + .object({ + start: z.iso.datetime(), + end: z.iso.datetime(), + }) + .optional(), + scenarioId: z.uuidv4(), + scenarioVersion: z.number().optional(), + trigger: z.array(triggerFilter), +}); + +export type AnalyticsQuery = z.infer; + +export const transformAnalyticsQuery = analyticsQuery.transform((val): AnalyticsQueryDto[] => { + return [ + { + start: startOfDay(val.dateRange.start).toISOString(), + end: startOfDay(val.dateRange.end).toISOString(), + scenario_id: val.scenarioId, + scenario_versions: val.scenarioVersion ? [val.scenarioVersion] : [], + trigger: val.trigger, + }, + ...(val.compareDateRange + ? [ + { + start: startOfDay(val.compareDateRange.start).toISOString(), + end: startOfDay(val.compareDateRange.end).toISOString(), + scenario_id: val.scenarioId, + scenario_versions: val.scenarioVersion ? [val.scenarioVersion] : [], + trigger: val.trigger, + }, + ] + : []), + ]; +}); diff --git a/packages/app-builder/src/models/analytics/rule-hit.ts b/packages/app-builder/src/models/analytics/rule-hit.ts new file mode 100644 index 0000000000..e3f7623e52 --- /dev/null +++ b/packages/app-builder/src/models/analytics/rule-hit.ts @@ -0,0 +1,19 @@ +import { RuleHitTableResponseDto } from 'marble-api'; + +export type RuleHitTableResponse = { + ruleName: string; + hitCount: number; + hitRatio: number; + pivotCount: number; + pivotRatio: number; +}; + +export const adaptRuleHitTable = (val: RuleHitTableResponseDto[]): RuleHitTableResponse[] => { + return val.map((v) => ({ + ruleName: v.rule_name, + hitCount: v.hit_count, + hitRatio: v.hit_ratio, + pivotCount: v.pivot_count, + pivotRatio: v.pivot_ratio, + })); +}; diff --git a/packages/app-builder/src/queries/analytics/get-analytics.ts b/packages/app-builder/src/queries/analytics/get-analytics.ts new file mode 100644 index 0000000000..618e599d28 --- /dev/null +++ b/packages/app-builder/src/queries/analytics/get-analytics.ts @@ -0,0 +1,65 @@ +import { useAgnosticNavigation } from '@app-builder/contexts/AgnosticNavigationContext'; +import { AnalyticsQuery, DecisionOutcomesPerPeriod } from '@app-builder/models/analytics'; +import { RuleHitTableResponse } from '@app-builder/models/analytics/rule-hit'; +import { getRoute } from '@app-builder/utils/routes'; +import { keepPreviousData, useQuery } from '@tanstack/react-query'; + +export const useGetAnalytics = ({ + scenarioId, + scenarioVersion, + dateRange, + compareDateRange = undefined, + trigger = [], +}: AnalyticsQuery) => { + const navigate = useAgnosticNavigation(); + const endpoint = getRoute('/ressources/analytics/:scenarioId/query', { + scenarioId, + }); + return useQuery({ + queryKey: [ + 'analytics', + 'query', + scenarioId, + scenarioVersion, + dateRange, + compareDateRange, + trigger, + ], + queryFn: async () => { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + scenarioId, + scenarioVersion, + dateRange: { + start: dateRange.start, + end: dateRange.end, + }, + compareDateRange: compareDateRange + ? { + start: compareDateRange.start, + end: compareDateRange.end, + } + : undefined, + trigger, + }), + }); + + const result = await response.json(); + + if (result.redirectTo) { + navigate(result.redirectTo); + return; + } + + return result as { + decisionOutcomesPerDay: DecisionOutcomesPerPeriod | null; + ruleHitTable: RuleHitTableResponse[] | null; + }; + }, + placeholderData: keepPreviousData, + }); +}; diff --git a/packages/app-builder/src/queries/analytics/get-available-filters.ts b/packages/app-builder/src/queries/analytics/get-available-filters.ts new file mode 100644 index 0000000000..6e9c338380 --- /dev/null +++ b/packages/app-builder/src/queries/analytics/get-available-filters.ts @@ -0,0 +1,37 @@ +import { useAgnosticNavigation } from '@app-builder/contexts/AgnosticNavigationContext'; +import { AvailableFiltersResponse } from '@app-builder/models/analytics/available-filters'; +import { getRoute } from '@app-builder/utils/routes'; +import { useQuery } from '@tanstack/react-query'; + +export const useGetAvailableFilters = ({ + scenarioId, + dateRange, +}: { + scenarioId: string; + dateRange: { start: string; end: string }; +}) => { + const navigate = useAgnosticNavigation(); + + const endpoint = getRoute('/ressources/analytics/:scenarioId/available_filters', { + scenarioId, + }); + return useQuery({ + queryKey: ['analytics', 'available-filters', scenarioId, dateRange], + queryFn: async () => { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ scenarioId, start: dateRange.start, end: dateRange.end }), + }); + const result = await response.json(); + if (result.redirectTo) { + navigate(result.redirectTo); + return; + } + + return result as Promise; + }, + }); +}; diff --git a/packages/app-builder/src/repositories/AnalyticsRepository.ts b/packages/app-builder/src/repositories/AnalyticsRepository.ts index f24c897210..18bf123792 100644 --- a/packages/app-builder/src/repositories/AnalyticsRepository.ts +++ b/packages/app-builder/src/repositories/AnalyticsRepository.ts @@ -1,22 +1,29 @@ import { type MarbleCoreApi } from '@app-builder/infra/marblecore-api'; import { + type AnalyticsQuery, adaptDecisionOutcomesPerDay, - type DecisionOutcomesPerDayQuery, type DecisionOutcomesPerPeriod, fillMissingDays, LimitDate, legacyAnalytics, mergeDateRanges, - transformDecisionOutcomesPerDayQuery, + transformAnalyticsQuery, } from '@app-builder/models/analytics'; +import { + type AvailableFiltersRequest, + type AvailableFiltersResponse, + adaptAvailableFiltersResponse, + transformAvailableFiltersRequest, +} from '@app-builder/models/analytics/available-filters'; +import { adaptRuleHitTable, RuleHitTableResponse } from '@app-builder/models/analytics/rule-hit'; import { compareAsc, compareDesc, differenceInDays } from 'date-fns'; export interface AnalyticsRepository { legacyListAnalytics(): Promise; - getDecisionOutcomesPerDay( - args: DecisionOutcomesPerDayQuery, - ): Promise; + getDecisionOutcomesPerDay(args: AnalyticsQuery): Promise; + getRuleHitTable(args: AnalyticsQuery): Promise; + getAvailableFilters(args: AvailableFiltersRequest): Promise; } export function makeGetAnalyticsRepository() { @@ -29,12 +36,11 @@ export function makeGetAnalyticsRepository() { }, getDecisionOutcomesPerDay: async ( - args: DecisionOutcomesPerDayQuery, + args: AnalyticsQuery, ): Promise => { - const parsed = transformDecisionOutcomesPerDayQuery.parse(args); + const parsed = transformAnalyticsQuery.parse(args); if (!parsed.length) throw new Error('No date range provided'); - console.log('parsed', parsed); try { const [raw, rawCompare] = await Promise.all([ client.getDecisionOutcomesPerDay(parsed[0]!), @@ -85,5 +91,21 @@ export function makeGetAnalyticsRepository() { return null; } }, + + getRuleHitTable: async (args: AnalyticsQuery): Promise => { + const parsed = transformAnalyticsQuery.parse(args); + if (!parsed.length) throw new Error('No date range provided'); + + const raw = await client.getRuleHitTable(parsed[0]!); + return adaptRuleHitTable(raw); + }, + + getAvailableFilters: async ( + args: AvailableFiltersRequest, + ): Promise => { + return client + .getAvailableFilters(transformAvailableFiltersRequest(args)) + .then((response) => adaptAvailableFiltersResponse(response)); + }, }); } diff --git a/packages/app-builder/src/routes/_builder+/_analytics+/analytics.$scenarioId.tsx b/packages/app-builder/src/routes/_builder+/_analytics+/analytics.$scenarioId.tsx index 3adb79ccfe..482eb1c521 100644 --- a/packages/app-builder/src/routes/_builder+/_analytics+/analytics.$scenarioId.tsx +++ b/packages/app-builder/src/routes/_builder+/_analytics+/analytics.$scenarioId.tsx @@ -1,24 +1,69 @@ import { ErrorComponent } from '@app-builder/components'; import { Decisions } from '@app-builder/components/Analytics/Decisions'; -import { Filters } from '@app-builder/components/Analytics/Filters'; +import { RulesHit } from '@app-builder/components/Analytics/RulesHit'; import { BreadCrumbLink, type BreadCrumbProps } from '@app-builder/components/Breadcrumbs'; +import { type DecisionOutcomesPerPeriod, FilterSource } from '@app-builder/models/analytics'; +import { RuleHitTableResponse } from '@app-builder/models/analytics/rule-hit'; +import { type Scenario } from '@app-builder/models/scenario'; +import { useGetAnalytics } from '@app-builder/queries/analytics/get-analytics'; +import { useGetAvailableFilters } from '@app-builder/queries/analytics/get-available-filters'; import { initServerServices } from '@app-builder/services/init.server'; +import { formatDateTimeWithoutPresets, formatDuration } from '@app-builder/utils/format'; import { getRoute } from '@app-builder/utils/routes'; -import { fromParams, fromUUIDtoSUUID } from '@app-builder/utils/short-uuid'; +import { fromParams, fromUUIDtoSUUID, useParam } from '@app-builder/utils/short-uuid'; import { type LoaderFunctionArgs, redirect } from '@remix-run/node'; -import { useLoaderData, useNavigate, useRouteError, useSearchParams } from '@remix-run/react'; +import { useLoaderData, useRouteError, useSearchParams } from '@remix-run/react'; import { captureRemixErrorBoundaryError } from '@sentry/remix'; +import { useQueryClient } from '@tanstack/react-query'; import { subMonths } from 'date-fns'; import { type Namespace } from 'i18next'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { UUID } from 'short-uuid'; +import { FiltersBar, FormattingProvider, I18nProvider } from 'ui-design-system'; +import type { + DateRangeFilterType, + FilterChange, + FilterDescriptor, +} from 'ui-design-system/src/FiltersBar/types'; import { Icon } from 'ui-icons'; import z from 'zod'; +interface LoaderData { + scenarioId: string; + scenarios: Scenario[]; + scenarioVersions: Array<{ + version: number; + createdAt: string; + }>; +} + +// Schema for parsing URL query parameters +const qSchema = z + .object({ + range: z.object({ start: z.iso.datetime(), end: z.iso.datetime() }), + compareRange: z + .object({ start: z.iso.datetime(), end: z.iso.datetime() }) + .nullable() + .optional(), + }) + .refine((v) => new Date(v.range.start).getTime() <= new Date(v.range.end).getTime(), { + message: 'Invalid date range payload', + }) + .refine( + (v) => { + const cr = v.compareRange; + if (!cr) return true; + return new Date(cr.start).getTime() <= new Date(cr.end).getTime(); + }, + { message: 'Invalid compare range payload' }, + ); + export const handle = { - i18n: ['navigation', 'data'] satisfies Namespace, + i18n: ['navigation', 'filters', 'analytics'] satisfies Namespace, BreadCrumbs: [ ({ isLast }: BreadCrumbProps) => { - const { t } = useTranslation(['navigation']); + const { t } = useTranslation(['navigation', 'filters', 'analytics']); return ( @@ -30,9 +75,9 @@ export const handle = { ], }; -export async function loader({ request, params }: LoaderFunctionArgs) { +export async function loader({ request, params }: LoaderFunctionArgs): Promise { const { authService } = initServerServices(request); - const { analytics, scenario } = await authService.isAuthenticated(request, { + const { scenario } = await authService.isAuthenticated(request, { failureRedirect: getRoute('/sign-in'), }); @@ -50,26 +95,6 @@ export async function loader({ request, params }: LoaderFunctionArgs) { return redirect(url.toString()); }; - const qSchema = z - .object({ - range: z.object({ start: z.iso.datetime(), end: z.iso.datetime() }), - compareRange: z - .object({ start: z.iso.datetime(), end: z.iso.datetime() }) - .nullable() - .optional(), - }) - .refine((v) => new Date(v.range.start).getTime() <= new Date(v.range.end).getTime(), { - message: 'Invalid date range payload', - }) - .refine( - (v) => { - const cr = v.compareRange; - if (!cr) return true; - return new Date(cr.start).getTime() <= new Date(cr.end).getTime(); - }, - { message: 'Invalid compare range payload' }, - ); - let parsed: { range: { start: string; end: string }; compareRange: { start: string; end: string } | null; @@ -99,21 +124,12 @@ export async function loader({ request, params }: LoaderFunctionArgs) { }); } - const [decisionsOutcomesPerDay, scenarios, scenarioIterations] = await Promise.all([ - analytics.getDecisionOutcomesPerDay({ - scenarioId, - dateRange: { start: parsed.range.start, end: parsed.range.end }, - compareDateRange: parsed.compareRange - ? { start: parsed.compareRange.start, end: parsed.compareRange.end } - : undefined, - trigger: [], - }), + const [scenarios, scenarioIterations] = await Promise.all([ scenario.listScenarios(), scenario.listScenarioIterations({ scenarioId }), ]); return Response.json({ - decisionsOutcomesPerDay, scenarioId, scenarios, scenarioVersions: scenarioIterations @@ -126,30 +142,298 @@ export async function loader({ request, params }: LoaderFunctionArgs) { } export default function Analytics() { - const { decisionsOutcomesPerDay, scenarioId, scenarios, scenarioVersions } = - useLoaderData(); - const navigate = useNavigate(); + const { scenarios, scenarioVersions } = useLoaderData(); + const urlScenarioId = useParam('scenarioId'); const [searchParams] = useSearchParams(); + const queryClient = useQueryClient(); + const { t, i18n } = useTranslation(['filters', 'analytics']); + + const [scenarioId, setScenarioId] = useState(urlScenarioId); + + type DynamicKind = 'text' | 'number' | 'boolean'; + type DynamicMeta = Record; + const [dynamicMeta, setDynamicMeta] = useState({}); + + useEffect(() => { + setScenarioId(urlScenarioId); + }, [urlScenarioId]); + + const q = searchParams.get('q'); + let parsedDateRange: { + range: { start: string; end: string }; + compareRange: { start: string; end: string } | null; + } | null = null; + + if (q) { + try { + const obj = JSON.parse(atob(q)); + const safe = qSchema.safeParse(obj); + if (safe.success) { + parsedDateRange = { range: safe.data.range, compareRange: safe.data.compareRange ?? null }; + } + } catch { + // fallthrough: parsedDateRange stays null + } + } + + const initialRange = + parsedDateRange?.range ?? + (() => { + const now = new Date(); + now.setUTCHours(0, 0, 0, 0); + const startDefault = subMonths(now, 1); + startDefault.setUTCHours(0, 0, 0, 0); + return { start: startDefault.toISOString(), end: now.toISOString() }; + })(); + const initialCompare = parsedDateRange?.compareRange ?? null; + + const [filtersValue, setFiltersValue] = useState>({ + scenario: urlScenarioId, + dateRange: { + type: 'static', + startDate: initialRange.start, + endDate: initialRange.end, + } satisfies DateRangeFilterType, + compareDateRange: initialCompare + ? ({ type: 'static', startDate: initialCompare.start, endDate: initialCompare.end } as const) + : null, + }); + const [activeDynamicFilters, setActiveDynamicFilters] = useState([]); + const deriveIsoRange = (v: unknown): { start: string; end: string } | null => { + const r = v as DateRangeFilterType | null | undefined; + if (!r) return null; + if (r.type === 'static') return { start: String(r.startDate), end: String(r.endDate) }; + return null; // ignore dynamic for backend queries + }; + + const currentRange = deriveIsoRange(filtersValue['dateRange']); + const currentCompareRange = deriveIsoRange(filtersValue['compareDateRange']); + + const { data: availableFilters } = useGetAvailableFilters({ + scenarioId, + dateRange: currentRange ?? initialRange, + }); - const onScenariochange = (scenarioId: string) => { - const qs = searchParams.toString(); - const path = getRoute('/analytics/:scenarioId', { scenarioId: fromUUIDtoSUUID(scenarioId) }); - navigate(qs ? `${path}?${qs}` : path); + type AvailableFiltersDescriptor = FilterDescriptor & { + source?: FilterSource; }; + const { dynamicDescriptors, dynamicIndex } = useMemo(() => { + const index: DynamicMeta = {}; + const descriptors: AvailableFiltersDescriptor[] = (availableFilters ?? []).map((filter) => { + const common = { + name: filter.name, + placeholder: filter.name, + removable: true as const, + source: filter.source, + }; + switch (filter.type) { + case 'string': + index[filter.name] = { source: filter.source, kind: 'text' }; + return { ...common, type: 'text', operator: 'in' as const }; + case 'number': + index[filter.name] = { source: filter.source, kind: 'number' }; + return { ...common, type: 'number', operator: 'eq' as const }; + case 'boolean': + index[filter.name] = { source: filter.source, kind: 'boolean' }; + return { ...common, type: 'boolean' as const }; + } + }) as AvailableFiltersDescriptor[]; + + return { dynamicDescriptors: descriptors, dynamicIndex: index }; + }, [availableFilters]); + + useEffect(() => { + const allowed = new Set(Object.keys(dynamicIndex)); + setActiveDynamicFilters((prev) => prev.filter((n) => allowed.has(n))); + setFiltersValue((prev) => { + const next = { ...prev }; + for (const key of Object.keys(prev)) { + if (key === 'scenario' || key === 'dateRange' || key === 'compareDateRange') continue; + if (!allowed.has(key)) delete next[key]; + } + return next; + }); + setDynamicMeta(dynamicIndex); + queryClient.invalidateQueries({ queryKey: ['analytics'] }); + }, [dynamicIndex, queryClient]); + + const trigger = useMemo(() => { + const out: { + field: string; + op: '=' | '!=' | '>' | '>=' | '<' | '<=' | 'in'; + values: string[]; + }[] = []; + for (const name of activeDynamicFilters) { + const meta = dynamicMeta[name]; + if (!meta) continue; + const v = filtersValue[name]; + + if (meta.kind === 'boolean') { + if (typeof v === 'boolean') out.push({ field: name, op: '=', values: [String(v)] }); + continue; + } + if (meta.kind === 'number') { + const c = v as { + operator: 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte'; + value: number; + } | null; + if (c && c.value !== undefined && c.value !== null) { + const opMap: Record< + 'eq' | 'ne' | 'lt' | 'lte' | 'gt' | 'gte', + '=' | '!=' | '>' | '>=' | '<' | '<=' + > = { + eq: '=', + ne: '!=', + lt: '<', + lte: '<=', + gt: '>', + gte: '>=', + }; + out.push({ field: name, op: opMap[c.operator], values: [String(c.value)] }); + } + continue; + } + // text + const c = (v as { operator: 'in'; value: string }[] | null) ?? []; + const values = c.map((x) => x.value).filter(Boolean); + if (values.length) out.push({ field: name, op: 'in', values }); + } + return out; + }, [activeDynamicFilters, filtersValue, dynamicMeta]); + + const { + data: { decisionOutcomesPerDay: decisionsData, ruleHitTable: ruleHitTableData } = { + decisionOutcomesPerDay: null, + ruleHitTable: [], + }, + isFetching: isDecisionsPending, + } = useGetAnalytics({ + scenarioId, + scenarioVersion: undefined, + dateRange: currentRange ?? initialRange, + compareDateRange: currentCompareRange ?? undefined, + trigger, + }); + + // Invalidate query when URL search params change + const prevSearchParamsRef = useRef(''); + + useEffect(() => { + const currentSearchParams = searchParams.toString(); + const searchParamsChanged = + prevSearchParamsRef.current && prevSearchParamsRef.current !== currentSearchParams; + + if (searchParamsChanged) { + // Invalidate all analytics queries when search params change + queryClient.invalidateQueries({ queryKey: ['analytics'] }); + } + + prevSearchParamsRef.current = currentSearchParams; + }, [searchParams, queryClient]); + + const onFiltersChange = ( + change: FilterChange, + next: { value: Record; active: string[] }, + ) => { + console.log('onFiltersChange', change, next); + console.log(next.active); + setFiltersValue(next.value); + setActiveDynamicFilters(next.active); + console.log(filtersValue); + + // Handle scenario id routing + if (change.type === 'set' && change.name === 'scenario') { + const newScenarioId = change.value as string | null; + if (newScenarioId) { + const qs = searchParams.toString(); + const path = getRoute('/analytics/:scenarioId', { + scenarioId: fromUUIDtoSUUID(newScenarioId as UUID), + }); + const newUrl = qs ? `${path}?${qs}` : path; + window.history.replaceState({}, '', newUrl); + setScenarioId(newScenarioId as UUID); + return; + } + } + + // Always sync date range params in URL + const nextRange = deriveIsoRange(next.value['dateRange']) ?? initialRange; + const nextCompare = deriveIsoRange(next.value['compareDateRange']); + const url = new URL(window.location.href); + url.searchParams.set( + 'q', + btoa( + JSON.stringify({ + range: nextRange, + compareRange: nextCompare, + }), + ), + ); + window.history.replaceState({}, '', url.toString()); + + queryClient.invalidateQueries({ queryKey: ['analytics'] }); + }; + const descriptors: FilterDescriptor[] = [ + { + type: 'select', + name: 'scenario', + placeholder: t('analytics:filters.select_scenario.placeholder'), + options: scenarios.map((scenario) => ({ label: scenario.name, value: scenario.id })), + }, + { + type: 'date-range-popover', + name: 'dateRange', + placeholder: t('analytics:filters.select_date_range.placeholder'), + }, + { + type: 'date-range-popover', + name: 'compareDateRange', + placeholder: t('analytics:filters.select_comparison_date_range.placeholder'), + removable: true, + }, + ]; return ( -
-
-
- + + formatDateTimeWithoutPresets(d, { language: i18n.language, ...(opts ?? {}) }), + formatDuration: (dur, lang) => formatDuration(dur, lang ?? i18n.language), + }} + > + ) => string, + }} + > +
+
+
+
+ +
+
+ + +
-
- -
+ + ); } @@ -162,19 +446,8 @@ export function ErrorBoundary() { return ; } -export function shouldRevalidate({ - currentParams, - nextParams, - currentUrl, - nextUrl, -}: { - currentParams: any; - nextParams: any; - currentUrl: URL; - nextUrl: URL; -}) { - // Revalidate when scenarioId or date range search params change - if (currentParams.scenarioId !== nextParams.scenarioId) return true; - const keys = ['q'] as const; - return keys.some((k) => currentUrl.searchParams.get(k) !== nextUrl.searchParams.get(k)); +export function shouldRevalidate() { + // Always return false to prevent full page re-renders + // Query invalidation is handled in the component's useEffect + return false; } diff --git a/packages/app-builder/src/routes/ressources+/analytics+/$scenarioId+/available_filters.ts b/packages/app-builder/src/routes/ressources+/analytics+/$scenarioId+/available_filters.ts new file mode 100644 index 0000000000..831e7c3354 --- /dev/null +++ b/packages/app-builder/src/routes/ressources+/analytics+/$scenarioId+/available_filters.ts @@ -0,0 +1,30 @@ +import { initServerServices } from '@app-builder/services/init.server'; +import { getRoute } from '@app-builder/utils/routes'; +import { ActionFunctionArgs } from '@remix-run/node'; +import { z } from 'zod/v4'; + +const urlParamsSchema = z.object({ + scenarioId: z.uuidv4(), +}); + +const queryParamsSchema = z.object({ + start: z.iso.datetime(), + end: z.iso.datetime(), +}); + +export async function action({ request, params }: ActionFunctionArgs) { + const { authService } = initServerServices(request); + const { analytics } = await authService.isAuthenticated(request, { + failureRedirect: getRoute('/sign-in'), + }); + + const urlParams = urlParamsSchema.parse(params); + const queryParams = queryParamsSchema.parse(await request.json()); + + const query = await analytics.getAvailableFilters({ + scenarioId: urlParams.scenarioId, + start: queryParams.start, + end: queryParams.end, + }); + return Response.json(query); +} diff --git a/packages/app-builder/src/routes/ressources+/analytics+/$scenarioId+/query.ts b/packages/app-builder/src/routes/ressources+/analytics+/$scenarioId+/query.ts new file mode 100644 index 0000000000..86300a5a16 --- /dev/null +++ b/packages/app-builder/src/routes/ressources+/analytics+/$scenarioId+/query.ts @@ -0,0 +1,60 @@ +import { initServerServices } from '@app-builder/services/init.server'; +import { getRoute } from '@app-builder/utils/routes'; +import { type ActionFunctionArgs } from '@remix-run/node'; +import { z } from 'zod'; + +const urlParamsSchema = z.object({ + scenarioId: z.uuidv4(), +}); + +const queryParamsSchema = z.object({ + dateRange: z.object({ + start: z.iso.datetime(), + end: z.iso.datetime(), + }), + compareDateRange: z + .object({ + start: z.iso.datetime(), + end: z.iso.datetime(), + }) + .optional(), + scenarioVersion: z.number().optional(), + trigger: z + .array( + z.object({ + field: z.uuidv4(), + op: z.enum(['=', '!=', '>', '>=', '<', '<=']), + values: z.array(z.string()), + }), + ) + .optional() + .default([]), +}); + +export async function action({ params, request }: ActionFunctionArgs) { + const { authService } = initServerServices(request); + const { analytics } = await authService.isAuthenticated(request, { + failureRedirect: getRoute('/sign-in'), + }); + + const urlParams = urlParamsSchema.parse(params); + + const body = await request.json(); + const queryParams = queryParamsSchema.parse(body); + + const [decisionOutcomesPerDay, ruleHitTable] = await Promise.all([ + await analytics.getDecisionOutcomesPerDay({ + ...queryParams, + scenarioId: urlParams.scenarioId, + }), + await analytics.getRuleHitTable({ + ...queryParams, + scenarioId: urlParams.scenarioId, + }), + ]); + + return Response.json({ + decisionOutcomesPerDay, + ruleHitTable, + }); +} diff --git a/packages/app-builder/src/routes/ressources+/analytics+/query.$queryName.ts b/packages/app-builder/src/routes/ressources+/analytics+/query.$queryName.ts deleted file mode 100644 index 0304c5ff04..0000000000 --- a/packages/app-builder/src/routes/ressources+/analytics+/query.$queryName.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { type DecisionOutcomesPerDayQuery } from '@app-builder/models/analytics'; -import { initServerServices } from '@app-builder/services/init.server'; -import { getRoute } from '@app-builder/utils/routes'; -import { ActionFunctionArgs } from '@remix-run/node'; -import { useParams } from '@remix-run/react'; - -export async function action({ request }: ActionFunctionArgs) { - const { queryName } = useParams(); - const { authService } = initServerServices(request); - await authService.isAuthenticated(request, { - failureRedirect: getRoute('/sign-in'), - }); - - const { analytics } = await authService.isAuthenticated(request, { - failureRedirect: getRoute('/sign-in'), - }); - - const query = await analytics.getDecisionOutcomesPerDay( - queryName as unknown as DecisionOutcomesPerDayQuery, - ); - return Response.json(query); -} diff --git a/packages/app-builder/src/utils/routes/routes.ts b/packages/app-builder/src/utils/routes/routes.ts index ceaa94f63a..e07aa505f9 100644 --- a/packages/app-builder/src/utils/routes/routes.ts +++ b/packages/app-builder/src/utils/routes/routes.ts @@ -371,9 +371,14 @@ export const routes = [ "file": "routes/healthcheck.ts" }, { - "id": "routes/ressources+/analytics+/query.$queryName", - "path": "ressources/analytics/query/:queryName", - "file": "routes/ressources+/analytics+/query.$queryName.ts" + "id": "routes/ressources+/analytics+/$scenarioId+/available_filters", + "path": "ressources/analytics/:scenarioId/available_filters", + "file": "routes/ressources+/analytics+/$scenarioId+/available_filters.ts" + }, + { + "id": "routes/ressources+/analytics+/$scenarioId+/query", + "path": "ressources/analytics/:scenarioId/query", + "file": "routes/ressources+/analytics+/$scenarioId+/query.ts" }, { "id": "routes/ressources+/annotations+/download-file.$annotationId.$fileId", diff --git a/packages/app-builder/src/utils/routes/types.ts b/packages/app-builder/src/utils/routes/types.ts index 80eb0a8cf1..b16ab8c714 100644 --- a/packages/app-builder/src/utils/routes/types.ts +++ b/packages/app-builder/src/utils/routes/types.ts @@ -53,7 +53,8 @@ export type RoutePath = | '/upload/:objectType' | '/app-router' | '/healthcheck' - | '/ressources/analytics/query/:queryName' + | '/ressources/analytics/:scenarioId/available_filters' + | '/ressources/analytics/:scenarioId/query' | '/ressources/annotations/download-file/:annotationId/:fileId' | '/ressources/app-config' | '/ressources/auth/logout' @@ -246,7 +247,8 @@ export type RouteID = | 'routes/_index' | 'routes/app-router' | 'routes/healthcheck' - | 'routes/ressources+/analytics+/query.$queryName' + | 'routes/ressources+/analytics+/$scenarioId+/available_filters' + | 'routes/ressources+/analytics+/$scenarioId+/query' | 'routes/ressources+/annotations+/download-file.$annotationId.$fileId' | 'routes/ressources+/app-config' | 'routes/ressources+/auth+/logout' diff --git a/packages/marble-api/openapis/marblecore-api.yaml b/packages/marble-api/openapis/marblecore-api.yaml index 2f854c4ed1..af1d62317f 100644 --- a/packages/marble-api/openapis/marblecore-api.yaml +++ b/packages/marble-api/openapis/marblecore-api.yaml @@ -363,6 +363,10 @@ paths: /analytics/query/decision_outcomes_per_day: $ref: ./marblecore-api/analytics.yml#/~1analytics~1query~1decision_outcomes_per_day + /analytics/query/rule_hit_table: + $ref: ./marblecore-api/analytics.yml#/~1analytics~1query~1rule_hit_table + /analytics/available-filters: + $ref: ./marblecore-api/analytics.yml#/~1analytics~1available-filters components: securitySchemes: diff --git a/packages/marble-api/openapis/marblecore-api/_schemas.yml b/packages/marble-api/openapis/marblecore-api/_schemas.yml index b6986e891d..9ebdcc0c0a 100644 --- a/packages/marble-api/openapis/marblecore-api/_schemas.yml +++ b/packages/marble-api/openapis/marblecore-api/_schemas.yml @@ -470,9 +470,15 @@ UpsertAISettingsDto: # ANALYTICS -DecisionOutcomesPerDayQueryDto: - $ref: analytics.yml#/components/schemas/DecisionOutcomesPerDayQueryDto +AnalyticsQueryDto: + $ref: analytics.yml#/components/schemas/AnalyticsQueryDto DecisionOutcomesPerDayResponseDto: $ref: analytics.yml#/components/schemas/DecisionOutcomesPerDayResponseDto +RuleHitTableResponseDto: + $ref: analytics.yml#/components/schemas/RuleHitTableResponseDto TriggerFilterDto: $ref: analytics.yml#/components/schemas/TriggerFilterDto +AvailableFiltersRequestDto: + $ref: analytics.yml#/components/schemas/AvailableFiltersRequestDto +AvailableFiltersResponseDto: + $ref: analytics.yml#/components/schemas/AvailableFiltersResponseDto diff --git a/packages/marble-api/openapis/marblecore-api/analytics.yml b/packages/marble-api/openapis/marblecore-api/analytics.yml index 3fc2adee05..7330a44665 100644 --- a/packages/marble-api/openapis/marblecore-api/analytics.yml +++ b/packages/marble-api/openapis/marblecore-api/analytics.yml @@ -11,7 +11,7 @@ content: application/json: schema: - $ref: '#/components/schemas/DecisionOutcomesPerDayQueryDto' + $ref: '#/components/schemas/AnalyticsQueryDto' responses: '400': $ref: 'components.yml#/responses/400' @@ -29,7 +29,58 @@ $ref: 'components.yml#/responses/403' '404': $ref: 'components.yml#/responses/404' - +/analytics/query/rule_hit_table: + post: + tags: + - Analytics + summary: Get rule hit table + operationId: getRuleHitTable + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AnalyticsQueryDto' + responses: + '400': + $ref: 'components.yml#/responses/400' + '200': + description: The rule hit table + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/RuleHitTableResponseDto' + '401': + $ref: 'components.yml#/responses/401' + '403': + $ref: 'components.yml#/responses/403' + '404': + $ref: 'components.yml#/responses/404' +/analytics/available-filters: + post: + tags: + - Analytics + summary: Get available filters + operationId: getAvailableFilters + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AvailableFiltersRequestDto' + responses: + '200': + description: The available filters + content: + application/json: + schema: + $ref: '#/components/schemas/AvailableFiltersResponseDto' components: schemas: ScenarioFilterDto: @@ -76,7 +127,7 @@ components: - type: string - type: number - type: boolean - DecisionOutcomesPerDayQueryDto: + AnalyticsQueryDto: allOf: - $ref: '#/components/schemas/ScenarioFilterDto' - $ref: '#/components/schemas/DateRangeFilterDto' @@ -108,4 +159,55 @@ components: type: integer review: type: integer + RuleHitTableResponseDto: + type: object + required: + - rule_name + - hit_count + - hit_ratio + - pivot_count + - pivot_ratio + properties: + rule_name: + type: string + hit_count: + type: integer + hit_ratio: + type: number + pivot_count: + type: integer + pivot_ratio: + type: number + AvailableFiltersRequestDto: + type: object + required: + - scenario_id + - start + - end + properties: + scenario_id: + type: string + format: uuid + start: + type: string + format: date-time + end: + type: string + format: date-time + AvailableFiltersResponseDto: + type: array + items: + type: object + required: + - name + - type + - source + properties: + name: + type: string + type: + type: string + source: + type: string + enum: ['trigger_object'] diff --git a/packages/marble-api/src/generated/marblecore-api.ts b/packages/marble-api/src/generated/marblecore-api.ts index c77a626342..b0c0694e16 100644 --- a/packages/marble-api/src/generated/marblecore-api.ts +++ b/packages/marble-api/src/generated/marblecore-api.ts @@ -1386,7 +1386,7 @@ export type TriggerFilterDto = { op: "=" | "!=" | ">" | ">=" | "<" | "<=" | "in"; values: (string | number | boolean)[]; }; -export type DecisionOutcomesPerDayQueryDto = { +export type AnalyticsQueryDto = { scenario_id: string; scenario_versions: number[]; } & { @@ -1402,6 +1402,23 @@ export type DecisionOutcomesPerDayResponseDto = { decline: number; review: number; }; +export type RuleHitTableResponseDto = { + rule_name: string; + hit_count: number; + hit_ratio: number; + pivot_count: number; + pivot_ratio: number; +}; +export type AvailableFiltersRequestDto = { + scenario_id: string; + start: string; + end: string; +}; +export type AvailableFiltersResponseDto = { + name: string; + "type": string; + source: "trigger_object"; +}[]; /** * Get an access token */ @@ -4927,7 +4944,7 @@ export function upsertAiSettings(upsertAiSettingsDto: UpsertAiSettingsDto, opts? /** * Get decision outcomes per day */ -export function getDecisionOutcomesPerDay(decisionOutcomesPerDayQueryDto: DecisionOutcomesPerDayQueryDto, opts?: Oazapfts.RequestOpts) { +export function getDecisionOutcomesPerDay(analyticsQueryDto: AnalyticsQueryDto, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; data: DecisionOutcomesPerDayResponseDto[]; @@ -4946,6 +4963,44 @@ export function getDecisionOutcomesPerDay(decisionOutcomesPerDayQueryDto: Decisi }>("/analytics/query/decision_outcomes_per_day", oazapfts.json({ ...opts, method: "POST", - body: decisionOutcomesPerDayQueryDto + body: analyticsQueryDto + }))); +} +/** + * Get rule hit table + */ +export function getRuleHitTable(analyticsQueryDto: AnalyticsQueryDto, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: RuleHitTableResponseDto[]; + } | { + status: 400; + data: string; + } | { + status: 401; + data: string; + } | { + status: 403; + data: string; + } | { + status: 404; + data: string; + }>("/analytics/query/rule_hit_table", oazapfts.json({ + ...opts, + method: "POST", + body: analyticsQueryDto + }))); +} +/** + * Get available filters + */ +export function getAvailableFilters(availableFiltersRequestDto: AvailableFiltersRequestDto, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: AvailableFiltersResponseDto; + }>("/analytics/available-filters", oazapfts.json({ + ...opts, + method: "POST", + body: availableFiltersRequestDto }))); }