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)"
|
||||
href={`/${params.organizationId}/${projectId}/settings/profile`}
|
||||
/>
|
||||
<LinkWithIcon
|
||||
icon={UserIcon}
|
||||
label="References"
|
||||
href={`/${params.organizationId}/${projectId}/settings/references`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{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,
|
||||
endDate,
|
||||
}: ReportChartProps) {
|
||||
const [references] = api.reference.getChartReferences.useSuspenseQuery({
|
||||
projectId,
|
||||
startDate,
|
||||
endDate,
|
||||
range,
|
||||
});
|
||||
|
||||
const [data] = api.chart.chart.useSuspenseQuery(
|
||||
{
|
||||
// dont send lineType since it does not need to be sent
|
||||
@@ -80,7 +87,12 @@ export function Chart({
|
||||
|
||||
if (chartType === 'linear') {
|
||||
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"
|
||||
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>
|
||||
{({ width, height }) => (
|
||||
<AreaChart
|
||||
|
||||
@@ -11,11 +11,13 @@ import {
|
||||
CartesianGrid,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceLine,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
import type { IServiceReference } from '@mixan/db';
|
||||
import type { IChartLineType, IInterval } from '@mixan/validation';
|
||||
|
||||
import { getYAxisWidth } from './chart-utils';
|
||||
@@ -26,6 +28,7 @@ import { ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
interface ReportLineChartProps {
|
||||
data: IChartData;
|
||||
references: IServiceReference[];
|
||||
interval: IInterval;
|
||||
lineType: IChartLineType;
|
||||
}
|
||||
@@ -34,17 +37,35 @@ export function ReportLineChart({
|
||||
lineType,
|
||||
interval,
|
||||
data,
|
||||
references,
|
||||
}: ReportLineChartProps) {
|
||||
const { editMode, previous } = useChartContext();
|
||||
const formatDate = useFormatDateInterval(interval);
|
||||
const { series, setVisibleSeries } = useVisibleSeries(data);
|
||||
const rechartData = useRechartDataModel(series);
|
||||
const number = useNumber();
|
||||
console.log(references.map((ref) => ref.createdAt.getTime()));
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResponsiveContainer>
|
||||
{({ width, height }) => (
|
||||
<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
|
||||
strokeDasharray="3 3"
|
||||
horizontal={true}
|
||||
@@ -62,10 +83,12 @@ export function ReportLineChart({
|
||||
<XAxis
|
||||
axisLine={false}
|
||||
fontSize={12}
|
||||
dataKey="date"
|
||||
tickFormatter={(m: string) => formatDate(m)}
|
||||
dataKey="timestamp"
|
||||
scale="utc"
|
||||
domain={['dataMin', 'dataMax']}
|
||||
tickFormatter={(m: string) => formatDate(new Date(m))}
|
||||
type="number"
|
||||
tickLine={false}
|
||||
allowDuplicatedCategory={false}
|
||||
/>
|
||||
{series.map((serie) => {
|
||||
return (
|
||||
|
||||
@@ -10,21 +10,19 @@ const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof PopoverPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
|
||||
>(({ className, align = 'center', sideOffset = 4, ...props }, ref) => (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
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',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
boxShadow: '0 0 0 9000px rgba(0, 0, 0, 0.05)',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
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',
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
boxShadow: '0 0 0 9000px rgba(0, 0, 0, 0.05)',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ export function useRechartDataModel(series: IChartData['series']) {
|
||||
series[0]?.data.map(({ date }) => {
|
||||
return {
|
||||
date,
|
||||
timestamp: new Date(date).getTime(),
|
||||
...series.reduce((acc, serie, idx) => {
|
||||
return {
|
||||
...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'), {
|
||||
loading: Loading,
|
||||
}),
|
||||
AddReference: dynamic(() => import('./AddReference'), {
|
||||
loading: Loading,
|
||||
}),
|
||||
};
|
||||
|
||||
const emitter = mitt<{
|
||||
|
||||
@@ -8,6 +8,7 @@ import { onboardingRouter } from './routers/onboarding';
|
||||
import { organizationRouter } from './routers/organization';
|
||||
import { profileRouter } from './routers/profile';
|
||||
import { projectRouter } from './routers/project';
|
||||
import { referenceRouter } from './routers/reference';
|
||||
import { reportRouter } from './routers/report';
|
||||
import { shareRouter } from './routers/share';
|
||||
import { uiRouter } from './routers/ui';
|
||||
@@ -31,6 +32,7 @@ export const appRouter = createTRPCRouter({
|
||||
ui: uiRouter,
|
||||
share: shareRouter,
|
||||
onboarding: onboardingRouter,
|
||||
reference: referenceRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getDaysOldDate } from '@/utils/date';
|
||||
import { round } from '@/utils/math';
|
||||
import * as mathjs from 'mathjs';
|
||||
import { sort } from 'ramda';
|
||||
@@ -7,6 +8,7 @@ import { chQuery, convertClickhouseDateToJs, getChartSql } from '@mixan/db';
|
||||
import type {
|
||||
IChartEvent,
|
||||
IChartInput,
|
||||
IChartRange,
|
||||
IGetChartDataInput,
|
||||
IInterval,
|
||||
} 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,
|
||||
publicProcedure,
|
||||
} from '@/server/api/trpc';
|
||||
import { getDaysOldDate } from '@/utils/date';
|
||||
import { average, max, min, round, sum } from '@/utils/math';
|
||||
import {
|
||||
flatten,
|
||||
map,
|
||||
pick,
|
||||
pipe,
|
||||
prop,
|
||||
repeat,
|
||||
reverse,
|
||||
sort,
|
||||
uniq,
|
||||
} from 'ramda';
|
||||
import { flatten, map, pipe, prop, repeat, reverse, sort, uniq } from 'ramda';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { chQuery, createSqlBuilder } from '@mixan/db';
|
||||
import { chQuery, createSqlBuilder, formatClickhouseDate } from '@mixan/db';
|
||||
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) {
|
||||
return {
|
||||
totalSessions: 0,
|
||||
@@ -40,7 +35,7 @@ async function getFunnelData(payload: IChartInput) {
|
||||
session_id,
|
||||
windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${payload.events.map((event) => `name = '${event.name}'`).join(', ')}) AS level
|
||||
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 level
|
||||
@@ -50,7 +45,7 @@ async function getFunnelData(payload: IChartInput) {
|
||||
const [funnelRes, sessionRes] = await Promise.all([
|
||||
chQuery<{ level: number; count: number }>(sql),
|
||||
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
|
||||
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
|
||||
const current =
|
||||
input.startDate && input.endDate
|
||||
? { startDate: input.startDate, endDate: input.endDate }
|
||||
: getDatesFromRange(input.range);
|
||||
const { startDate, endDate } = getChartStartEndDate(input);
|
||||
let diff = 0;
|
||||
|
||||
switch (input.range) {
|
||||
@@ -320,11 +312,9 @@ export const chartRouter = createTRPCRouter({
|
||||
...input,
|
||||
...{
|
||||
startDate: new Date(
|
||||
new Date(current.startDate).getTime() - diff
|
||||
).toISOString(),
|
||||
endDate: new Date(
|
||||
new Date(current.endDate).getTime() - diff
|
||||
new Date(startDate).getTime() - diff
|
||||
).toISOString(),
|
||||
endDate: new Date(new Date(endDate).getTime() - diff).toISOString(),
|
||||
},
|
||||
})
|
||||
);
|
||||
@@ -516,71 +506,4 @@ async function getSeriesFromEvents(input: IChartInput) {
|
||||
).flat();
|
||||
|
||||
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/share.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[]
|
||||
share ShareOverview?
|
||||
EventMeta EventMeta[]
|
||||
Reference Reference[]
|
||||
|
||||
@@map("projects")
|
||||
}
|
||||
@@ -184,3 +185,17 @@ model EventMeta {
|
||||
@@unique([name, project_id])
|
||||
@@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 zRange = z.enum(objectToZodEnums(timeRanges));
|
||||
|
||||
export const zChartInput = z.object({
|
||||
name: z.string(),
|
||||
chartType: zChartType,
|
||||
@@ -64,7 +66,7 @@ export const zChartInput = z.object({
|
||||
interval: zTimeInterval,
|
||||
events: zChartEvents,
|
||||
breakdowns: zChartBreakdowns,
|
||||
range: z.enum(objectToZodEnums(timeRanges)),
|
||||
range: zRange,
|
||||
previous: z.boolean(),
|
||||
formula: z.string().optional(),
|
||||
metric: zMetric,
|
||||
@@ -87,3 +89,10 @@ export const zShareOverview = z.object({
|
||||
password: z.string().nullable(),
|
||||
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