add references, chart improvement, fix scrollable combobox, fixed funnels
This commit is contained in:
@@ -127,6 +127,11 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
|||||||
label="Profile (yours)"
|
label="Profile (yours)"
|
||||||
href={`/${params.organizationId}/${projectId}/settings/profile`}
|
href={`/${params.organizationId}/${projectId}/settings/profile`}
|
||||||
/>
|
/>
|
||||||
|
<LinkWithIcon
|
||||||
|
icon={UserIcon}
|
||||||
|
label="References"
|
||||||
|
href={`/${params.organizationId}/${projectId}/settings/references`}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{dashboards.length > 0 && (
|
{dashboards.length > 0 && (
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
|
||||||
|
import { DataTable } from '@/components/DataTable';
|
||||||
|
import { columns } from '@/components/references/table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
|
import { PlusIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
import type { IServiceReference } from '@mixan/db';
|
||||||
|
|
||||||
|
interface ListProjectsProps {
|
||||||
|
data: IServiceReference[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ListReferences({ data }: ListProjectsProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<StickyBelowHeader>
|
||||||
|
<div className="p-4 flex items-center justify-between">
|
||||||
|
<div />
|
||||||
|
<Button icon={PlusIcon} onClick={() => pushModal('AddReference')}>
|
||||||
|
<span className="max-sm:hidden">Create reference</span>
|
||||||
|
<span className="sm:hidden">Reference</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</StickyBelowHeader>
|
||||||
|
<div className="p-4">
|
||||||
|
<DataTable data={data} columns={columns} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
|
||||||
|
import { getExists } from '@/server/pageExists';
|
||||||
|
|
||||||
|
import { getReferences } from '@mixan/db';
|
||||||
|
|
||||||
|
import ListReferences from './list-references';
|
||||||
|
|
||||||
|
interface PageProps {
|
||||||
|
params: {
|
||||||
|
organizationId: string;
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Page({
|
||||||
|
params: { organizationId, projectId },
|
||||||
|
}: PageProps) {
|
||||||
|
await getExists(organizationId, projectId);
|
||||||
|
const references = await getReferences({
|
||||||
|
where: {
|
||||||
|
project_id: projectId,
|
||||||
|
},
|
||||||
|
take: 50,
|
||||||
|
skip: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout title="References" organizationSlug={organizationId}>
|
||||||
|
<ListReferences data={references} />
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
apps/web/src/components/references/table.tsx
Normal file
27
apps/web/src/components/references/table.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { formatDate, formatDateTime } from '@/utils/date';
|
||||||
|
import type { ColumnDef } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import type { IServiceReference } from '@mixan/db';
|
||||||
|
|
||||||
|
export const columns: ColumnDef<IServiceReference>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'title',
|
||||||
|
header: 'Title',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'date',
|
||||||
|
header: 'Date',
|
||||||
|
cell({ row }) {
|
||||||
|
const date = row.original.date;
|
||||||
|
return <div>{formatDateTime(date)}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'createdAt',
|
||||||
|
header: 'Created at',
|
||||||
|
cell({ row }) {
|
||||||
|
const date = row.original.createdAt;
|
||||||
|
return <div>{formatDate(date)}</div>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -31,6 +31,13 @@ export function Chart({
|
|||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
}: ReportChartProps) {
|
}: ReportChartProps) {
|
||||||
|
const [references] = api.reference.getChartReferences.useSuspenseQuery({
|
||||||
|
projectId,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
range,
|
||||||
|
});
|
||||||
|
|
||||||
const [data] = api.chart.chart.useSuspenseQuery(
|
const [data] = api.chart.chart.useSuspenseQuery(
|
||||||
{
|
{
|
||||||
// dont send lineType since it does not need to be sent
|
// dont send lineType since it does not need to be sent
|
||||||
@@ -80,7 +87,12 @@ export function Chart({
|
|||||||
|
|
||||||
if (chartType === 'linear') {
|
if (chartType === 'linear') {
|
||||||
return (
|
return (
|
||||||
<ReportLineChart lineType={lineType} interval={interval} data={data} />
|
<ReportLineChart
|
||||||
|
lineType={lineType}
|
||||||
|
interval={interval}
|
||||||
|
data={data}
|
||||||
|
references={references}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function MetricCard({
|
|||||||
className="group relative card p-4 overflow-hidden h-24"
|
className="group relative card p-4 overflow-hidden h-24"
|
||||||
key={serie.name}
|
key={serie.name}
|
||||||
>
|
>
|
||||||
<div className="absolute -top-2 -left-2 -right-2 -bottom-2 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50 rounded-md">
|
<div className="absolute inset-0 -left-1 -right-1 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50 rounded-md">
|
||||||
<AutoSizer>
|
<AutoSizer>
|
||||||
{({ width, height }) => (
|
{({ width, height }) => (
|
||||||
<AreaChart
|
<AreaChart
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ import {
|
|||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
|
ReferenceLine,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
XAxis,
|
XAxis,
|
||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
|
|
||||||
|
import type { IServiceReference } from '@mixan/db';
|
||||||
import type { IChartLineType, IInterval } from '@mixan/validation';
|
import type { IChartLineType, IInterval } from '@mixan/validation';
|
||||||
|
|
||||||
import { getYAxisWidth } from './chart-utils';
|
import { getYAxisWidth } from './chart-utils';
|
||||||
@@ -26,6 +28,7 @@ import { ResponsiveContainer } from './ResponsiveContainer';
|
|||||||
|
|
||||||
interface ReportLineChartProps {
|
interface ReportLineChartProps {
|
||||||
data: IChartData;
|
data: IChartData;
|
||||||
|
references: IServiceReference[];
|
||||||
interval: IInterval;
|
interval: IInterval;
|
||||||
lineType: IChartLineType;
|
lineType: IChartLineType;
|
||||||
}
|
}
|
||||||
@@ -34,17 +37,35 @@ export function ReportLineChart({
|
|||||||
lineType,
|
lineType,
|
||||||
interval,
|
interval,
|
||||||
data,
|
data,
|
||||||
|
references,
|
||||||
}: ReportLineChartProps) {
|
}: ReportLineChartProps) {
|
||||||
const { editMode, previous } = useChartContext();
|
const { editMode, previous } = useChartContext();
|
||||||
const formatDate = useFormatDateInterval(interval);
|
const formatDate = useFormatDateInterval(interval);
|
||||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||||
const rechartData = useRechartDataModel(series);
|
const rechartData = useRechartDataModel(series);
|
||||||
const number = useNumber();
|
const number = useNumber();
|
||||||
|
console.log(references.map((ref) => ref.createdAt.getTime()));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
{({ width, height }) => (
|
{({ width, height }) => (
|
||||||
<LineChart width={width} height={height} data={rechartData}>
|
<LineChart width={width} height={height} data={rechartData}>
|
||||||
|
{references.map((ref) => (
|
||||||
|
<ReferenceLine
|
||||||
|
key={ref.id}
|
||||||
|
x={ref.date.getTime()}
|
||||||
|
stroke={'#94a3b8'}
|
||||||
|
strokeDasharray={'3 3'}
|
||||||
|
label={{
|
||||||
|
value: ref.title,
|
||||||
|
position: 'centerTop',
|
||||||
|
fill: '#334155',
|
||||||
|
fontSize: 12,
|
||||||
|
}}
|
||||||
|
fontSize={10}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
horizontal={true}
|
horizontal={true}
|
||||||
@@ -62,10 +83,12 @@ export function ReportLineChart({
|
|||||||
<XAxis
|
<XAxis
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
fontSize={12}
|
fontSize={12}
|
||||||
dataKey="date"
|
dataKey="timestamp"
|
||||||
tickFormatter={(m: string) => formatDate(m)}
|
scale="utc"
|
||||||
|
domain={['dataMin', 'dataMax']}
|
||||||
|
tickFormatter={(m: string) => formatDate(new Date(m))}
|
||||||
|
type="number"
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
allowDuplicatedCategory={false}
|
|
||||||
/>
|
/>
|
||||||
{series.map((serie) => {
|
{series.map((serie) => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -10,21 +10,19 @@ const PopoverContent = React.forwardRef<
|
|||||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||||
<PopoverPrimitive.Portal>
|
<PopoverPrimitive.Content
|
||||||
<PopoverPrimitive.Content
|
ref={ref}
|
||||||
ref={ref}
|
align={align}
|
||||||
align={align}
|
sideOffset={sideOffset}
|
||||||
sideOffset={sideOffset}
|
className={cn(
|
||||||
className={cn(
|
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
className
|
||||||
className
|
)}
|
||||||
)}
|
style={{
|
||||||
style={{
|
boxShadow: '0 0 0 9000px rgba(0, 0, 0, 0.05)',
|
||||||
boxShadow: '0 0 0 9000px rgba(0, 0, 0, 0.05)',
|
}}
|
||||||
}}
|
{...props}
|
||||||
{...props}
|
/>
|
||||||
/>
|
|
||||||
</PopoverPrimitive.Portal>
|
|
||||||
));
|
));
|
||||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export function useRechartDataModel(series: IChartData['series']) {
|
|||||||
series[0]?.data.map(({ date }) => {
|
series[0]?.data.map(({ date }) => {
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
|
timestamp: new Date(date).getTime(),
|
||||||
...series.reduce((acc, serie, idx) => {
|
...series.reduce((acc, serie, idx) => {
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
|
|||||||
68
apps/web/src/modals/AddReference.tsx
Normal file
68
apps/web/src/modals/AddReference.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { api, handleError } from '@/app/_trpc/client';
|
||||||
|
import { ButtonContainer } from '@/components/ButtonContainer';
|
||||||
|
import { InputWithLabel } from '@/components/forms/InputWithLabel';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { Controller, useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { z } from 'zod';
|
||||||
|
|
||||||
|
import { zCreateReference } from '@mixan/validation';
|
||||||
|
|
||||||
|
import { popModal } from '.';
|
||||||
|
import { ModalContent, ModalHeader } from './Modal/Container';
|
||||||
|
|
||||||
|
type IForm = z.infer<typeof zCreateReference>;
|
||||||
|
|
||||||
|
export default function AddReference() {
|
||||||
|
const { projectId } = useAppParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const { register, handleSubmit, formState } = useForm<IForm>({
|
||||||
|
resolver: zodResolver(zCreateReference),
|
||||||
|
defaultValues: {
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
projectId,
|
||||||
|
datetime: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = api.reference.create.useMutation({
|
||||||
|
onError: handleError,
|
||||||
|
onSuccess() {
|
||||||
|
router.refresh();
|
||||||
|
toast('Success', {
|
||||||
|
description: 'Reference created.',
|
||||||
|
});
|
||||||
|
popModal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent>
|
||||||
|
<ModalHeader title="Add reference" />
|
||||||
|
<form
|
||||||
|
className="flex flex-col gap-4"
|
||||||
|
onSubmit={handleSubmit((values) => mutation.mutate(values))}
|
||||||
|
>
|
||||||
|
<InputWithLabel label="Title" {...register('title')} />
|
||||||
|
<InputWithLabel label="Description" {...register('description')} />
|
||||||
|
<InputWithLabel label="Datetime" {...register('datetime')} />
|
||||||
|
<ButtonContainer>
|
||||||
|
<Button type="button" variant="outline" onClick={() => popModal()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!formState.isDirty}>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</ButtonContainer>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -45,6 +45,9 @@ const modals = {
|
|||||||
ShareOverviewModal: dynamic(() => import('./ShareOverviewModal'), {
|
ShareOverviewModal: dynamic(() => import('./ShareOverviewModal'), {
|
||||||
loading: Loading,
|
loading: Loading,
|
||||||
}),
|
}),
|
||||||
|
AddReference: dynamic(() => import('./AddReference'), {
|
||||||
|
loading: Loading,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const emitter = mitt<{
|
const emitter = mitt<{
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { onboardingRouter } from './routers/onboarding';
|
|||||||
import { organizationRouter } from './routers/organization';
|
import { organizationRouter } from './routers/organization';
|
||||||
import { profileRouter } from './routers/profile';
|
import { profileRouter } from './routers/profile';
|
||||||
import { projectRouter } from './routers/project';
|
import { projectRouter } from './routers/project';
|
||||||
|
import { referenceRouter } from './routers/reference';
|
||||||
import { reportRouter } from './routers/report';
|
import { reportRouter } from './routers/report';
|
||||||
import { shareRouter } from './routers/share';
|
import { shareRouter } from './routers/share';
|
||||||
import { uiRouter } from './routers/ui';
|
import { uiRouter } from './routers/ui';
|
||||||
@@ -31,6 +32,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
ui: uiRouter,
|
ui: uiRouter,
|
||||||
share: shareRouter,
|
share: shareRouter,
|
||||||
onboarding: onboardingRouter,
|
onboarding: onboardingRouter,
|
||||||
|
reference: referenceRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { getDaysOldDate } from '@/utils/date';
|
||||||
import { round } from '@/utils/math';
|
import { round } from '@/utils/math';
|
||||||
import * as mathjs from 'mathjs';
|
import * as mathjs from 'mathjs';
|
||||||
import { sort } from 'ramda';
|
import { sort } from 'ramda';
|
||||||
@@ -7,6 +8,7 @@ import { chQuery, convertClickhouseDateToJs, getChartSql } from '@mixan/db';
|
|||||||
import type {
|
import type {
|
||||||
IChartEvent,
|
IChartEvent,
|
||||||
IChartInput,
|
IChartInput,
|
||||||
|
IChartRange,
|
||||||
IGetChartDataInput,
|
IGetChartDataInput,
|
||||||
IInterval,
|
IInterval,
|
||||||
} from '@mixan/validation';
|
} from '@mixan/validation';
|
||||||
@@ -286,3 +288,69 @@ export async function getChartData(payload: IGetChartDataInput) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getDatesFromRange(range: IChartRange) {
|
||||||
|
if (range === 'today') {
|
||||||
|
const startDate = new Date();
|
||||||
|
const endDate = new Date();
|
||||||
|
startDate.setUTCHours(0, 0, 0, 0);
|
||||||
|
endDate.setUTCHours(23, 59, 59, 999);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: startDate.toUTCString(),
|
||||||
|
endDate: endDate.toUTCString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (range === '30min' || range === '1h') {
|
||||||
|
const startDate = new Date(
|
||||||
|
Date.now() - 1000 * 60 * (range === '30min' ? 30 : 60)
|
||||||
|
).toUTCString();
|
||||||
|
const endDate = new Date().toUTCString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let days = 1;
|
||||||
|
|
||||||
|
if (range === '24h') {
|
||||||
|
const startDate = getDaysOldDate(days);
|
||||||
|
const endDate = new Date();
|
||||||
|
return {
|
||||||
|
startDate: startDate.toUTCString(),
|
||||||
|
endDate: endDate.toUTCString(),
|
||||||
|
};
|
||||||
|
} else if (range === '7d') {
|
||||||
|
days = 7;
|
||||||
|
} else if (range === '14d') {
|
||||||
|
days = 14;
|
||||||
|
} else if (range === '1m') {
|
||||||
|
days = 30;
|
||||||
|
} else if (range === '3m') {
|
||||||
|
days = 90;
|
||||||
|
} else if (range === '6m') {
|
||||||
|
days = 180;
|
||||||
|
} else if (range === '1y') {
|
||||||
|
days = 365;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDate = getDaysOldDate(days);
|
||||||
|
startDate.setUTCHours(0, 0, 0, 0);
|
||||||
|
const endDate = new Date();
|
||||||
|
endDate.setUTCHours(23, 59, 59, 999);
|
||||||
|
return {
|
||||||
|
startDate: startDate.toUTCString(),
|
||||||
|
endDate: endDate.toUTCString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChartStartEndDate(
|
||||||
|
input: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>
|
||||||
|
) {
|
||||||
|
return input.startDate && input.endDate
|
||||||
|
? { startDate: input.startDate, endDate: input.endDate }
|
||||||
|
: getDatesFromRange(input.range);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,28 +3,23 @@ import {
|
|||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
publicProcedure,
|
publicProcedure,
|
||||||
} from '@/server/api/trpc';
|
} from '@/server/api/trpc';
|
||||||
import { getDaysOldDate } from '@/utils/date';
|
|
||||||
import { average, max, min, round, sum } from '@/utils/math';
|
import { average, max, min, round, sum } from '@/utils/math';
|
||||||
import {
|
import { flatten, map, pipe, prop, repeat, reverse, sort, uniq } from 'ramda';
|
||||||
flatten,
|
|
||||||
map,
|
|
||||||
pick,
|
|
||||||
pipe,
|
|
||||||
prop,
|
|
||||||
repeat,
|
|
||||||
reverse,
|
|
||||||
sort,
|
|
||||||
uniq,
|
|
||||||
} from 'ramda';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { chQuery, createSqlBuilder } from '@mixan/db';
|
import { chQuery, createSqlBuilder, formatClickhouseDate } from '@mixan/db';
|
||||||
import { zChartInput } from '@mixan/validation';
|
import { zChartInput } from '@mixan/validation';
|
||||||
import type { IChartEvent, IChartInput, IChartRange } from '@mixan/validation';
|
import type { IChartEvent, IChartInput } from '@mixan/validation';
|
||||||
|
|
||||||
import { getChartData, withFormula } from './chart.helpers';
|
import {
|
||||||
|
getChartData,
|
||||||
|
getChartStartEndDate,
|
||||||
|
getDatesFromRange,
|
||||||
|
withFormula,
|
||||||
|
} from './chart.helpers';
|
||||||
|
|
||||||
async function getFunnelData(payload: IChartInput) {
|
async function getFunnelData({ projectId, ...payload }: IChartInput) {
|
||||||
|
const { startDate, endDate } = getChartStartEndDate(payload);
|
||||||
if (payload.events.length === 0) {
|
if (payload.events.length === 0) {
|
||||||
return {
|
return {
|
||||||
totalSessions: 0,
|
totalSessions: 0,
|
||||||
@@ -40,7 +35,7 @@ async function getFunnelData(payload: IChartInput) {
|
|||||||
session_id,
|
session_id,
|
||||||
windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${payload.events.map((event) => `name = '${event.name}'`).join(', ')}) AS level
|
windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${payload.events.map((event) => `name = '${event.name}'`).join(', ')}) AS level
|
||||||
FROM events
|
FROM events
|
||||||
WHERE (created_at >= '2024-02-24') AND (created_at <= '2024-02-25')
|
WHERE (project_id = '${projectId}' AND created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')
|
||||||
GROUP BY session_id
|
GROUP BY session_id
|
||||||
)
|
)
|
||||||
GROUP BY level
|
GROUP BY level
|
||||||
@@ -50,7 +45,7 @@ async function getFunnelData(payload: IChartInput) {
|
|||||||
const [funnelRes, sessionRes] = await Promise.all([
|
const [funnelRes, sessionRes] = await Promise.all([
|
||||||
chQuery<{ level: number; count: number }>(sql),
|
chQuery<{ level: number; count: number }>(sql),
|
||||||
chQuery<{ count: number }>(
|
chQuery<{ count: number }>(
|
||||||
`SELECT count(name) as count FROM events WHERE name = 'session_start' AND (created_at >= '2024-02-24') AND (created_at <= '2024-02-25')`
|
`SELECT count(name) as count FROM events WHERE project_id = '${projectId}' AND name = 'session_start' AND (created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')`
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -270,10 +265,7 @@ export const chartRouter = createTRPCRouter({
|
|||||||
|
|
||||||
// TODO: Make this private
|
// TODO: Make this private
|
||||||
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
|
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
|
||||||
const current =
|
const { startDate, endDate } = getChartStartEndDate(input);
|
||||||
input.startDate && input.endDate
|
|
||||||
? { startDate: input.startDate, endDate: input.endDate }
|
|
||||||
: getDatesFromRange(input.range);
|
|
||||||
let diff = 0;
|
let diff = 0;
|
||||||
|
|
||||||
switch (input.range) {
|
switch (input.range) {
|
||||||
@@ -320,11 +312,9 @@ export const chartRouter = createTRPCRouter({
|
|||||||
...input,
|
...input,
|
||||||
...{
|
...{
|
||||||
startDate: new Date(
|
startDate: new Date(
|
||||||
new Date(current.startDate).getTime() - diff
|
new Date(startDate).getTime() - diff
|
||||||
).toISOString(),
|
|
||||||
endDate: new Date(
|
|
||||||
new Date(current.endDate).getTime() - diff
|
|
||||||
).toISOString(),
|
).toISOString(),
|
||||||
|
endDate: new Date(new Date(endDate).getTime() - diff).toISOString(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -516,71 +506,4 @@ async function getSeriesFromEvents(input: IChartInput) {
|
|||||||
).flat();
|
).flat();
|
||||||
|
|
||||||
return withFormula(input, series);
|
return withFormula(input, series);
|
||||||
// .sort((a, b) => {
|
|
||||||
// if (input.chartType === 'linear') {
|
|
||||||
// const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
|
|
||||||
// const sumB = b.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
|
|
||||||
// return sumB - sumA;
|
|
||||||
// } else {
|
|
||||||
// return b.metrics.sum - a.metrics.sum;
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDatesFromRange(range: IChartRange) {
|
|
||||||
if (range === 'today') {
|
|
||||||
const startDate = new Date();
|
|
||||||
const endDate = new Date();
|
|
||||||
startDate.setUTCHours(0, 0, 0, 0);
|
|
||||||
endDate.setUTCHours(23, 59, 59, 999);
|
|
||||||
|
|
||||||
return {
|
|
||||||
startDate: startDate.toUTCString(),
|
|
||||||
endDate: endDate.toUTCString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (range === '30min' || range === '1h') {
|
|
||||||
const startDate = new Date(
|
|
||||||
Date.now() - 1000 * 60 * (range === '30min' ? 30 : 60)
|
|
||||||
).toUTCString();
|
|
||||||
const endDate = new Date().toUTCString();
|
|
||||||
|
|
||||||
return {
|
|
||||||
startDate,
|
|
||||||
endDate,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let days = 1;
|
|
||||||
|
|
||||||
if (range === '24h') {
|
|
||||||
const startDate = getDaysOldDate(days);
|
|
||||||
const endDate = new Date();
|
|
||||||
return {
|
|
||||||
startDate: startDate.toUTCString(),
|
|
||||||
endDate: endDate.toUTCString(),
|
|
||||||
};
|
|
||||||
} else if (range === '7d') {
|
|
||||||
days = 7;
|
|
||||||
} else if (range === '14d') {
|
|
||||||
days = 14;
|
|
||||||
} else if (range === '1m') {
|
|
||||||
days = 30;
|
|
||||||
} else if (range === '3m') {
|
|
||||||
days = 90;
|
|
||||||
} else if (range === '6m') {
|
|
||||||
days = 180;
|
|
||||||
} else if (range === '1y') {
|
|
||||||
days = 365;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDate = getDaysOldDate(days);
|
|
||||||
startDate.setUTCHours(0, 0, 0, 0);
|
|
||||||
const endDate = new Date();
|
|
||||||
endDate.setUTCHours(23, 59, 59, 999);
|
|
||||||
return {
|
|
||||||
startDate: startDate.toUTCString(),
|
|
||||||
endDate: endDate.toUTCString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
54
apps/web/src/server/api/routers/reference.ts
Normal file
54
apps/web/src/server/api/routers/reference.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { db, getReferences } from '@mixan/db';
|
||||||
|
import { zCreateReference, zRange } from '@mixan/validation';
|
||||||
|
|
||||||
|
import { getChartStartEndDate } from './chart.helpers';
|
||||||
|
|
||||||
|
export const referenceRouter = createTRPCRouter({
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(zCreateReference)
|
||||||
|
.mutation(
|
||||||
|
async ({ input: { title, description, datetime, projectId } }) => {
|
||||||
|
return db.reference.create({
|
||||||
|
data: {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
project_id: projectId,
|
||||||
|
date: new Date(datetime),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
),
|
||||||
|
delete: protectedProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ input: { id } }) => {
|
||||||
|
return db.reference.delete({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
getChartReferences: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
startDate: z.string().nullish(),
|
||||||
|
endDate: z.string().nullish(),
|
||||||
|
range: zRange,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.query(({ input: { projectId, ...input } }) => {
|
||||||
|
const { startDate, endDate } = getChartStartEndDate(input);
|
||||||
|
return getReferences({
|
||||||
|
where: {
|
||||||
|
project_id: projectId,
|
||||||
|
date: {
|
||||||
|
gte: new Date(startDate),
|
||||||
|
lte: new Date(endDate),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -12,3 +12,4 @@ export * from './src/services/reports.service';
|
|||||||
export * from './src/services/salt.service';
|
export * from './src/services/salt.service';
|
||||||
export * from './src/services/share.service';
|
export * from './src/services/share.service';
|
||||||
export * from './src/services/user.service';
|
export * from './src/services/user.service';
|
||||||
|
export * from './src/services/reference.service';
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "references" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"project_id" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "references_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "references" ADD CONSTRAINT "references_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "references" ADD COLUMN "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -25,6 +25,7 @@ model Project {
|
|||||||
dashboards Dashboard[]
|
dashboards Dashboard[]
|
||||||
share ShareOverview?
|
share ShareOverview?
|
||||||
EventMeta EventMeta[]
|
EventMeta EventMeta[]
|
||||||
|
Reference Reference[]
|
||||||
|
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
}
|
}
|
||||||
@@ -184,3 +185,17 @@ model EventMeta {
|
|||||||
@@unique([name, project_id])
|
@@unique([name, project_id])
|
||||||
@@map("event_meta")
|
@@map("event_meta")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Reference {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
|
title String
|
||||||
|
description String?
|
||||||
|
date DateTime @default(now())
|
||||||
|
project_id String
|
||||||
|
project Project @relation(fields: [project_id], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
@@map("references")
|
||||||
|
}
|
||||||
|
|||||||
50
packages/db/src/services/reference.service.ts
Normal file
50
packages/db/src/services/reference.service.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
import type { Prisma, Reference } from '../prisma-client';
|
||||||
|
import { db } from '../prisma-client';
|
||||||
|
|
||||||
|
// import type { Report as DbReport } from '../prisma-client';
|
||||||
|
|
||||||
|
export type IServiceReference = Omit<Reference, 'project_id'> & {
|
||||||
|
projectId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function transform({
|
||||||
|
project_id,
|
||||||
|
...item
|
||||||
|
}: Reference): IServiceReference {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
projectId: project_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReferenceById(id: string) {
|
||||||
|
const reference = await db.reference.findUnique({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!reference) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transform(reference);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReferences({
|
||||||
|
where,
|
||||||
|
take,
|
||||||
|
skip,
|
||||||
|
}: {
|
||||||
|
where: Prisma.ReferenceWhereInput;
|
||||||
|
take?: number;
|
||||||
|
skip?: number;
|
||||||
|
}) {
|
||||||
|
const references = await db.reference.findMany({
|
||||||
|
where,
|
||||||
|
take: take ?? 50,
|
||||||
|
skip,
|
||||||
|
});
|
||||||
|
|
||||||
|
return references.map(transform);
|
||||||
|
}
|
||||||
@@ -57,6 +57,8 @@ export const zTimeInterval = z.enum(objectToZodEnums(intervals));
|
|||||||
|
|
||||||
export const zMetric = z.enum(objectToZodEnums(metrics));
|
export const zMetric = z.enum(objectToZodEnums(metrics));
|
||||||
|
|
||||||
|
export const zRange = z.enum(objectToZodEnums(timeRanges));
|
||||||
|
|
||||||
export const zChartInput = z.object({
|
export const zChartInput = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
chartType: zChartType,
|
chartType: zChartType,
|
||||||
@@ -64,7 +66,7 @@ export const zChartInput = z.object({
|
|||||||
interval: zTimeInterval,
|
interval: zTimeInterval,
|
||||||
events: zChartEvents,
|
events: zChartEvents,
|
||||||
breakdowns: zChartBreakdowns,
|
breakdowns: zChartBreakdowns,
|
||||||
range: z.enum(objectToZodEnums(timeRanges)),
|
range: zRange,
|
||||||
previous: z.boolean(),
|
previous: z.boolean(),
|
||||||
formula: z.string().optional(),
|
formula: z.string().optional(),
|
||||||
metric: zMetric,
|
metric: zMetric,
|
||||||
@@ -87,3 +89,10 @@ export const zShareOverview = z.object({
|
|||||||
password: z.string().nullable(),
|
password: z.string().nullable(),
|
||||||
public: z.boolean(),
|
public: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const zCreateReference = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string().nullish(),
|
||||||
|
projectId: z.string(),
|
||||||
|
datetime: z.string(),
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user