Skip to content

Commit 1717538

Browse files
authored
feat(in-app-analytics): Prepare new analytics page (#1123)
* feat: install Nivo core and bar package * feat: add new endpoint definition * refactor: mark exisiting models and repository function as legacy
1 parent 77f99f4 commit 1717538

File tree

13 files changed

+428
-40
lines changed

13 files changed

+428
-40
lines changed

bun.lock

Lines changed: 82 additions & 6 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,6 @@
11
{
22
"name": "marble-front",
33
"version": "0.0.0",
4-
"license": "MIT",
5-
"private": true,
6-
"type": "module",
7-
"sideEffect": false,
8-
"workspaces": [
9-
"packages/*"
10-
],
11-
"packageManager": "[email protected]",
12-
"scripts": {
13-
"format:write": "biome format . --write",
14-
"format:check": "biome format .",
15-
"test:all": "vitest",
16-
"update-deps": "bun update --latest -r -i; clear; bun pm ls --all > packages-licenses.txt"
17-
},
184
"devDependencies": {
195
"@biomejs/biome": "2.2.4",
206
"@vitejs/plugin-react": "4.3.4",
@@ -26,8 +12,22 @@
2612
"vite-tsconfig-paths": "^5.1.4",
2713
"vitest": "3.0.9"
2814
},
15+
"license": "MIT",
2916
"overrides": {
3017
"cookie": "^0.7.2",
3118
"cross-spawn": "^7.0.5"
32-
}
19+
},
20+
"packageManager": "[email protected]",
21+
"private": true,
22+
"scripts": {
23+
"format:write": "biome format . --write",
24+
"format:check": "biome format .",
25+
"test:all": "vitest",
26+
"update-deps": "bun update --latest -r -i; clear; bun pm ls --all > packages-licenses.txt"
27+
},
28+
"sideEffect": false,
29+
"type": "module",
30+
"workspaces": [
31+
"packages/*"
32+
]
3333
}

packages/app-builder/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@
4747
"@lottiefiles/react-lottie-player": "^3.6.0",
4848
"@marble/shared": "workspace:*",
4949
"@mcansh/http-helmet": "^0.13.0",
50+
"@nivo/bar": "^0.99.0",
51+
"@nivo/core": "^0.99.0",
5052
"@oazapfts/runtime": "^1.0.4",
5153
"@preact/signals-react": "^3.0.1",
5254
"@radix-ui/react-avatar": "^1.1.3",
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { DecisionOutcomesPerDayQueryDto, type DecisionOutcomesPerDayResponseDto } from 'marble-api';
2+
import z from 'zod';
3+
4+
export const decisionOutcomesPerDay = z.object({
5+
absolute: z.array(
6+
z.object({
7+
date: z.iso.datetime(),
8+
approve: z.number(),
9+
blockAndReview: z.number(),
10+
decline: z.number(),
11+
review: z.number(),
12+
total: z.number(),
13+
}),
14+
),
15+
ratio: z.array(
16+
z.object({
17+
date: z.iso.datetime(),
18+
approve: z.number(),
19+
blockAndReview: z.number(),
20+
decline: z.number(),
21+
review: z.number(),
22+
}),
23+
),
24+
});
25+
26+
export const triggerFilter = z.object({
27+
field: z.uuidv4(),
28+
op: z.enum(['=', '!=', '>', '>=', '<', '<=']),
29+
values: z.array(z.string()),
30+
});
31+
32+
export const decisionOutcomesPerDayQuery = z.object({
33+
start: z.date(),
34+
end: z.date(),
35+
scenarioId: z.uuidv4(),
36+
scenarioVersion: z.number().optional(),
37+
trigger: z.array(triggerFilter),
38+
});
39+
40+
export type DecisionOutcomesPerDay = z.infer<typeof decisionOutcomesPerDay>;
41+
export type DecisionOutcomesPerDayQuery = z.infer<typeof decisionOutcomesPerDayQuery>;
42+
43+
function toUtcDayKey(date: Date): string {
44+
const y = date.getUTCFullYear();
45+
const m = `${date.getUTCMonth() + 1}`.padStart(2, '0');
46+
const d = `${date.getUTCDate()}`.padStart(2, '0');
47+
return `${y}-${m}-${d}`;
48+
}
49+
50+
// TODO: Helper function to be moved to a utils file
51+
function startOfUtcDay(date: Date): Date {
52+
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
53+
}
54+
55+
export function findMissingDays(
56+
data: DecisionOutcomesPerDayResponseDto[],
57+
start: Date,
58+
end: Date,
59+
): string[] {
60+
const startDay = startOfUtcDay(start);
61+
const endDay = startOfUtcDay(end);
62+
63+
const existing = new Set(data.map((d) => toUtcDayKey(new Date(d.date))));
64+
65+
const missing: string[] = [];
66+
for (let t = startDay.getTime(); t <= endDay.getTime(); t += 24 * 60 * 60 * 1000) {
67+
const key = toUtcDayKey(new Date(t));
68+
if (!existing.has(key)) missing.push(key);
69+
}
70+
return missing;
71+
}
72+
73+
// Fill the days without data with zero values between [start, end]
74+
export function fillMissingDays(
75+
data: DecisionOutcomesPerDayResponseDto[],
76+
start: Date,
77+
end: Date,
78+
): DecisionOutcomesPerDayResponseDto[] {
79+
const missing = findMissingDays(data, start, end);
80+
const filled = [
81+
...data,
82+
...missing.map((key) => ({
83+
date: `${key}T00:00:00.000Z`,
84+
approve: 0,
85+
block_and_review: 0,
86+
decline: 0,
87+
review: 0,
88+
})),
89+
];
90+
filled.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
91+
return filled;
92+
}
93+
94+
export const transformDecisionOutcomesPerDayQuery = decisionOutcomesPerDayQuery.transform(
95+
(val): DecisionOutcomesPerDayQueryDto => {
96+
return {
97+
start: val.start.toISOString(),
98+
end: val.end.toISOString(),
99+
scenario_id: val.scenarioId,
100+
scenario_versions: val.scenarioVersion ? [val.scenarioVersion] : [],
101+
trigger: val.trigger,
102+
};
103+
},
104+
);
105+
106+
export const adaptDecisionOutcomesPerDay = z.array(z.any()).transform(
107+
(val: DecisionOutcomesPerDayResponseDto[]): DecisionOutcomesPerDay => ({
108+
absolute: val.map((v) => ({
109+
date: v.date,
110+
approve: v.approve,
111+
blockAndReview: v.block_and_review,
112+
decline: v.decline,
113+
review: v.review,
114+
total: v.approve + v.block_and_review + v.decline + v.review,
115+
})),
116+
ratio: val.map((v) => {
117+
const total = v.approve + v.block_and_review + v.decline + v.review;
118+
return {
119+
date: v.date,
120+
approve: total ? (100 * v.approve) / total : 0,
121+
blockAndReview: total ? (100 * v.block_and_review) / total : 0,
122+
decline: total ? (100 * v.decline) / total : 0,
123+
review: total ? (100 * v.review) / total : 0,
124+
};
125+
}),
126+
}),
127+
);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export * from './decisions-outcomes-perday';
2+
export * as legacyAnalytics from './legacy-analytics';
3+
4+
export type Outcome = 'approve' | 'review' | 'blockAndReview' | 'decline';
5+
export type DecisionsFilter = Map<Outcome, boolean>;
6+
7+
export const outcomeColors: Record<Outcome, string> = {
8+
approve: '#89D4AE',
9+
review: '#FBDD82',
10+
blockAndReview: '#FFECE6',
11+
decline: '#E99B8E',
12+
};

packages/app-builder/src/models/analytics.ts renamed to packages/app-builder/src/models/analytics/legacy-analytics.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1-
import { type AnalyticsDto } from 'marble-api';
1+
import { type LegacyAnalyticsDto } from 'marble-api';
22

33
export interface Analytics {
44
embeddingType: 'global_dashboard' | 'unknown_embedding_type';
55
signedEmbeddingUrl: string;
66
}
77

8-
export function adaptAnalytics(analyticsDto: AnalyticsDto): Analytics {
8+
export function adaptAnalytics(analyticsDto: LegacyAnalyticsDto): Analytics {
99
return {
1010
embeddingType: analyticsDto.embedding_type,
1111
signedEmbeddingUrl: analyticsDto.signed_embedding_url,
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
11
import { type MarbleCoreApi } from '@app-builder/infra/marblecore-api';
2-
import { type Analytics, adaptAnalytics } from '@app-builder/models/analytics';
2+
import { legacyAnalytics } from '@app-builder/models/analytics';
33

44
export interface AnalyticsRepository {
5-
listAnalytics(): Promise<Analytics[]>;
5+
legacyListAnalytics(): Promise<legacyAnalytics.Analytics[]>;
66
}
77

88
export function makeGetAnalyticsRepository() {
99
return (marbleCoreApiClient: MarbleCoreApi): AnalyticsRepository => ({
10-
listAnalytics: async () => {
11-
const { analytics } = await marbleCoreApiClient.listAnalytics();
10+
legacyListAnalytics: async () => {
11+
const { analytics } = await marbleCoreApiClient.legacyListAnalytics();
1212

13-
return analytics.map(adaptAnalytics);
13+
return analytics.map(legacyAnalytics.adaptAnalytics);
1414
},
1515
});
1616
}

packages/app-builder/src/routes/_builder+/analytics.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { isAnalyticsAvailable } from '@app-builder/services/feature-access';
88
import { initServerServices } from '@app-builder/services/init.server';
99
import { notFound } from '@app-builder/utils/http/http-responses';
1010
import { getRoute } from '@app-builder/utils/routes';
11-
import { json, type LoaderFunctionArgs, redirect } from '@remix-run/node';
11+
import { type LoaderFunctionArgs, redirect } from '@remix-run/node';
1212
import { useLoaderData, useRouteError } from '@remix-run/react';
1313
import { captureRemixErrorBoundaryError } from '@sentry/remix';
1414
import { type Namespace } from 'i18next';
@@ -41,15 +41,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
4141
return redirect(getRoute('/'));
4242
}
4343

44-
const analyticsList = await analytics.listAnalytics();
44+
const analyticsList = await analytics.legacyListAnalytics();
4545
const globalDashbord = analyticsList.find(
4646
({ embeddingType }) => embeddingType === 'global_dashboard',
4747
);
4848
if (!globalDashbord) {
4949
return notFound("Global dashboard doesn't exist");
5050
}
5151

52-
return json({
52+
return Response.json({
5353
globalDashbord: {
5454
title: 'Global Dashboard',
5555
src: globalDashbord.signedEmbeddingUrl,

packages/marble-api/openapis/marblecore-api.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,11 @@ paths:
353353
/settings/ai:
354354
$ref: ./marblecore-api/ai.yml#/~1settings~1ai
355355

356+
# ANALYTICS
357+
358+
/analytics/query/decision_outcomes_per_day:
359+
$ref: ./marblecore-api/analytics.yml#/~1analytics~1query~1decision_outcomes_per_day
360+
356361
components:
357362
securitySchemes:
358363
$ref: marblecore-api/_common.yml#/securitySchemes

packages/marble-api/openapis/marblecore-api/_schemas.yml

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -333,8 +333,8 @@ SetDataModelTableOptionsBodyDto:
333333

334334
# MISC
335335

336-
AnalyticsDto:
337-
$ref: misc.yml#/components/schemas/AnalyticsDto
336+
LegacyAnalyticsDto:
337+
$ref: misc.yml#/components/schemas/LegacyAnalyticsDto
338338
AppConfigDto:
339339
$ref: misc.yml#/components/schemas/AppConfigDto
340340

@@ -456,4 +456,13 @@ CaseReviewSettingDto:
456456
AISettingsDto:
457457
$ref: ai.yml#/components/schemas/AISettingsDto
458458
UpsertAISettingsDto:
459-
$ref: ai.yml#/components/schemas/UpsertAISettingsDto
459+
$ref: ai.yml#/components/schemas/UpsertAISettingsDto
460+
461+
# ANALYTICS
462+
463+
DecisionOutcomesPerDayQueryDto:
464+
$ref: analytics.yml#/components/schemas/DecisionOutcomesPerDayQueryDto
465+
DecisionOutcomesPerDayResponseDto:
466+
$ref: analytics.yml#/components/schemas/DecisionOutcomesPerDayResponseDto
467+
TriggerFilterDto:
468+
$ref: analytics.yml#/components/schemas/TriggerFilterDto

0 commit comments

Comments
 (0)