-
Max
-
{data.metrics.max}
+
+
+ Total: {number.format(data.metrics.sum)}
+ Average: {number.format(data.metrics.average)}
+ Min: {number.format(data.metrics.min)}
+ Max: {number.format(data.metrics.max)}
+
>
);
diff --git a/apps/web/src/components/report/chart/index.tsx b/apps/web/src/components/report/chart/index.tsx
index 4fef6605..c1811edb 100644
--- a/apps/web/src/components/report/chart/index.tsx
+++ b/apps/web/src/components/report/chart/index.tsx
@@ -25,6 +25,7 @@ export const Chart = memo(
name,
range,
lineType,
+ previous,
}: ReportChartProps) {
const params = useAppParams();
const hasEmptyFilters = events.some((event) =>
@@ -44,6 +45,7 @@ export const Chart = memo(
startDate: null,
endDate: null,
projectId: params.projectId,
+ previous,
},
{
keepPreviousData: false,
diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/web/src/components/report/reportSlice.ts
index 63cc802f..118eb4c3 100644
--- a/apps/web/src/components/report/reportSlice.ts
+++ b/apps/web/src/components/report/reportSlice.ts
@@ -36,6 +36,7 @@ const initialState: InitialState = {
range: '1m',
startDate: null,
endDate: null,
+ previous: false,
};
export const reportSlice = createSlice({
diff --git a/apps/web/src/components/ui/table.tsx b/apps/web/src/components/ui/table.tsx
index df94c8a6..8386924f 100644
--- a/apps/web/src/components/ui/table.tsx
+++ b/apps/web/src/components/ui/table.tsx
@@ -91,7 +91,7 @@ const TableCell = React.forwardRef<
{
+ return (val: string | null) => {
return mappings.find((item) => item.id === val)?.name ?? val;
};
}
diff --git a/apps/web/src/hooks/useNumerFormatter.ts b/apps/web/src/hooks/useNumerFormatter.ts
new file mode 100644
index 00000000..c448476a
--- /dev/null
+++ b/apps/web/src/hooks/useNumerFormatter.ts
@@ -0,0 +1,11 @@
+export function useNumber() {
+ const locale = 'en-gb';
+
+ return {
+ format: (value: number) => {
+ return new Intl.NumberFormat(locale, {
+ maximumSignificantDigits: 20,
+ }).format(value);
+ },
+ };
+}
diff --git a/apps/web/src/hooks/useRechartDataModel.ts b/apps/web/src/hooks/useRechartDataModel.ts
index 19177a58..61f2229a 100644
--- a/apps/web/src/hooks/useRechartDataModel.ts
+++ b/apps/web/src/hooks/useRechartDataModel.ts
@@ -1,8 +1,9 @@
import { useMemo } from 'react';
-import type { IChartData } from '@/app/_trpc/client';
-import { alphabetIds } from '@/utils/constants';
+import type { IChartData, IChartSerieDataItem } from '@/app/_trpc/client';
import { getChartColor } from '@/utils/theme';
+export type IRechartPayloadItem = IChartSerieDataItem & { color: string };
+
export function useRechartDataModel(data: IChartData) {
return useMemo(() => {
return (
@@ -15,11 +16,14 @@ export function useRechartDataModel(data: IChartData) {
...serie.data.reduce(
(acc2, item) => {
if (item.date === date) {
+ if (item.previous) {
+ acc2[`${idx}:prev:count`] = item.previous.count;
+ }
acc2[`${idx}:count`] = item.count;
acc2[`${idx}:payload`] = {
...item,
color: getChartColor(idx),
- };
+ } satisfies IRechartPayloadItem;
}
return acc2;
},
diff --git a/apps/web/src/hooks/useVisibleSeries.ts b/apps/web/src/hooks/useVisibleSeries.ts
index 5d61bda6..a6030eb1 100644
--- a/apps/web/src/hooks/useVisibleSeries.ts
+++ b/apps/web/src/hooks/useVisibleSeries.ts
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
- const max = limit ?? 20;
+ const max = limit ?? 5;
const [visibleSeries, setVisibleSeries] = useState([]);
const ref = useRef(false);
useEffect(() => {
diff --git a/apps/web/src/server/api/routers/chart.ts b/apps/web/src/server/api/routers/chart.ts
index 5e78218b..bbb24a15 100644
--- a/apps/web/src/server/api/routers/chart.ts
+++ b/apps/web/src/server/api/routers/chart.ts
@@ -119,75 +119,199 @@ export const chartRouter = createTRPCRouter({
chart: protectedProcedure
.input(zChartInputWithDates.merge(z.object({ projectId: z.string() })))
- .query(async ({ input: { projectId, events, ...input } }) => {
- const { startDate, endDate } =
- input.startDate && input.endDate
- ? {
- startDate: input.startDate,
- endDate: input.endDate,
- }
- : getDatesFromRange(input.range);
- const series: Awaited> = [];
- for (const event of events) {
- const result = await getChartData({
- ...input,
- startDate,
- endDate,
- event,
- projectId: projectId,
- });
- series.push(...result);
+ .query(async ({ input }) => {
+ const current = getDatesFromRange(input.range);
+ let diff = 0;
+
+ switch (input.range) {
+ case '24h':
+ case 'today': {
+ diff = 1000 * 60 * 60 * 24;
+ break;
+ }
+ case '7d': {
+ diff = 1000 * 60 * 60 * 24 * 17;
+ break;
+ }
+ case '14d': {
+ diff = 1000 * 60 * 60 * 24 * 14;
+ break;
+ }
+ case '1m': {
+ diff = 1000 * 60 * 60 * 24 * 30;
+ break;
+ }
+ case '3m': {
+ diff = 1000 * 60 * 60 * 24 * 90;
+ break;
+ }
+ case '6m': {
+ diff = 1000 * 60 * 60 * 24 * 180;
+ break;
+ }
}
- const sorted = [...series].sort((a, b) => {
- if (input.chartType === 'linear') {
- const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
- const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
- return sumB - sumA;
- } else {
- return b.metrics.sum - a.metrics.sum;
- }
- });
+ const promises = [wrapper(input)];
- const metrics = {
- max: Math.max(...sorted.map((item) => item.metrics.max)),
- min: Math.min(...sorted.map((item) => item.metrics.min)),
- sum: sum(sorted.map((item) => item.metrics.sum, 0)),
- averge: round(
- average(sorted.map((item) => item.metrics.average, 0)),
- 2
- ),
- };
+ if (input.previous) {
+ promises.push(
+ wrapper({
+ ...input,
+ ...{
+ startDate: new Date(
+ new Date(current.startDate).getTime() - diff
+ ).toISOString(),
+ endDate: new Date(
+ new Date(current.endDate).getTime() - diff
+ ).toISOString(),
+ },
+ })
+ );
+ }
+
+ const awaitedPromises = await Promise.all(promises);
+ const data = awaitedPromises[0]!;
+ const previousData = awaitedPromises[1];
return {
- events: Object.entries(
- series.reduce(
- (acc, item) => {
- if (acc[item.event.id]) {
- acc[item.event.id] += item.metrics.sum;
- } else {
- acc[item.event.id] = item.metrics.sum;
- }
- return acc;
+ ...data,
+ series: data.series.map((item, sIndex) => {
+ function getPreviousDiff(key: keyof (typeof data)['metrics']) {
+ const prev = previousData?.series?.[sIndex]?.metrics?.[key];
+ const diff = getPreviousDataDiff(item.metrics[key], prev);
+
+ return diff && prev
+ ? {
+ diff: diff?.diff,
+ state: diff?.state,
+ value: prev,
+ }
+ : null;
+ }
+
+ return {
+ ...item,
+ metrics: {
+ ...item.metrics,
+ previous: {
+ sum: getPreviousDiff('sum'),
+ average: getPreviousDiff('average'),
+ },
},
- {} as Record<(typeof series)[number]['event']['id'], number>
- )
- ).map(([id, count]) => ({
- count,
- ...events.find((event) => event.id === id)!,
- })),
- series: sorted.map((item) => ({
- ...item,
- metrics: {
- ...item.metrics,
- totalMetrics: metrics,
- },
- })),
- metrics,
+ data: item.data.map((item, dIndex) => {
+ const diff = getPreviousDataDiff(
+ item.count,
+ previousData?.series?.[sIndex]?.data?.[dIndex]?.count
+ );
+ return {
+ ...item,
+ previous:
+ diff && previousData?.series?.[sIndex]?.data?.[dIndex]
+ ? Object.assign(
+ {},
+ previousData?.series?.[sIndex]?.data?.[dIndex],
+ diff
+ )
+ : null,
+ };
+ }),
+ };
+ }),
};
}),
});
+const chartValidator = zChartInputWithDates.merge(
+ z.object({ projectId: z.string() })
+);
+type ChartInput = z.infer;
+
+function getPreviousDataDiff(current: number, previous: number | undefined) {
+ if (!previous) {
+ return null;
+ }
+
+ const diff = round(
+ ((current > previous
+ ? current / previous
+ : current < previous
+ ? previous / current
+ : 0) -
+ 1) *
+ 100,
+ 1
+ );
+
+ return {
+ diff: Number.isNaN(diff) || !Number.isFinite(diff) ? null : diff,
+ state:
+ current > previous
+ ? 'positive'
+ : current < previous
+ ? 'negative'
+ : 'neutral',
+ };
+}
+
+async function wrapper({ events, projectId, ...input }: ChartInput) {
+ const { startDate, endDate } =
+ input.startDate && input.endDate
+ ? {
+ startDate: input.startDate,
+ endDate: input.endDate,
+ }
+ : getDatesFromRange(input.range);
+ const series: Awaited> = [];
+ for (const event of events) {
+ const result = await getChartData({
+ ...input,
+ startDate,
+ endDate,
+ event,
+ projectId: projectId,
+ });
+ series.push(...result);
+ }
+
+ const sorted = [...series].sort((a, b) => {
+ if (input.chartType === 'linear') {
+ const sumA = a.data.reduce((acc, item) => acc + item.count, 0);
+ const sumB = b.data.reduce((acc, item) => acc + item.count, 0);
+ return sumB - sumA;
+ } else {
+ return b.metrics.sum - a.metrics.sum;
+ }
+ });
+
+ const metrics = {
+ max: Math.max(...sorted.map((item) => item.metrics.max)),
+ min: Math.min(...sorted.map((item) => item.metrics.min)),
+ sum: sum(sorted.map((item) => item.metrics.sum, 0)),
+ average: round(average(sorted.map((item) => item.metrics.average, 0)), 2),
+ };
+
+ return {
+ events: Object.entries(
+ series.reduce(
+ (acc, item) => {
+ if (acc[item.event.id]) {
+ acc[item.event.id] += item.metrics.sum;
+ } else {
+ acc[item.event.id] = item.metrics.sum;
+ }
+ return acc;
+ },
+ {} as Record<(typeof series)[number]['event']['id'], number>
+ )
+ ).map(([id, count]) => ({
+ count,
+ ...events.find((event) => event.id === id)!,
+ })),
+ series: sorted,
+ metrics,
+ };
+}
+
interface ResultItem {
label: string | null;
count: number;
@@ -294,7 +418,9 @@ async function getChartData(payload: IGetChartDataInput) {
payload.chartType === 'area' ||
payload.chartType === 'linear' ||
payload.chartType === 'histogram' ||
- payload.chartType === 'metric'
+ payload.chartType === 'metric' ||
+ payload.chartType === 'pie' ||
+ payload.chartType === 'bar'
? fillEmptySpotsInTimeline(
series[key] ?? [],
payload.interval,
diff --git a/apps/web/src/server/services/reports.service.ts b/apps/web/src/server/services/reports.service.ts
index b7e62487..fe0983b5 100644
--- a/apps/web/src/server/services/reports.service.ts
+++ b/apps/web/src/server/services/reports.service.ts
@@ -51,6 +51,7 @@ export function transformReport(
interval: report.interval,
name: report.name || 'Untitled',
range: (report.range as IChartRange) ?? timeRanges['1m'],
+ previous: report.previous ?? false,
};
}
diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts
index dcf8be80..eb14d80b 100644
--- a/apps/web/src/utils/validation.ts
+++ b/apps/web/src/utils/validation.ts
@@ -51,6 +51,7 @@ export const zChartInput = z.object({
events: zChartEvents,
breakdowns: zChartBreakdowns,
range: z.enum(objectToZodEnums(timeRanges)),
+ previous: z.boolean(),
});
export const zChartInputWithDates = zChartInput.extend({
diff --git a/packages/db/prisma/migrations/20240121195834_add_previous_boolean_on_report/migration.sql b/packages/db/prisma/migrations/20240121195834_add_previous_boolean_on_report/migration.sql
new file mode 100644
index 00000000..f7d42b02
--- /dev/null
+++ b/packages/db/prisma/migrations/20240121195834_add_previous_boolean_on_report/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "reports" ADD COLUMN "previous" BOOLEAN NOT NULL DEFAULT false;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 045127ad..2fbdec1f 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -171,6 +171,7 @@ model Report {
events Json
project_id String
project Project @relation(fields: [project_id], references: [id])
+ previous Boolean @default(false)
dashboard_id String
dashboard Dashboard @relation(fields: [dashboard_id], references: [id])
|