add references, chart improvement, fix scrollable combobox, fixed funnels

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-07 20:23:34 +01:00
parent 6a6ccfdb42
commit 6d5bfa4cbe
21 changed files with 454 additions and 114 deletions

View File

@@ -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 && (

View File

@@ -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>
</>
);
}

View File

@@ -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>
);
}

View 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>;
},
},
];

View File

@@ -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}
/>
);
}

View File

@@ -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

View File

@@ -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 (

View File

@@ -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;

View File

@@ -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,

View 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>
);
}

View File

@@ -45,6 +45,9 @@ const modals = {
ShareOverviewModal: dynamic(() => import('./ShareOverviewModal'), {
loading: Loading,
}),
AddReference: dynamic(() => import('./AddReference'), {
loading: Loading,
}),
};
const emitter = mitt<{

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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(),
};
}

View 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),
},
},
});
}),
});

View File

@@ -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';

View File

@@ -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;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "references" ADD COLUMN "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@@ -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")
}

View 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);
}

View File

@@ -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(),
});