From 6d5bfa4cbe27abcf8e881b0e1b82226974e25d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Thu, 7 Mar 2024 20:23:34 +0100 Subject: [PATCH] add references, chart improvement, fix scrollable combobox, fixed funnels --- .../[projectId]/layout-menu.tsx | 5 + .../settings/references/list-references.tsx | 33 ++++++ .../[projectId]/settings/references/page.tsx | 32 +++++ apps/web/src/components/references/table.tsx | 27 +++++ .../web/src/components/report/chart/Chart.tsx | 14 ++- .../components/report/chart/MetricCard.tsx | 2 +- .../report/chart/ReportLineChart.tsx | 29 ++++- apps/web/src/components/ui/popover.tsx | 28 +++-- apps/web/src/hooks/useRechartDataModel.ts | 1 + apps/web/src/modals/AddReference.tsx | 68 +++++++++++ apps/web/src/modals/index.tsx | 3 + apps/web/src/server/api/root.ts | 2 + .../src/server/api/routers/chart.helpers.ts | 68 +++++++++++ apps/web/src/server/api/routers/chart.ts | 109 +++--------------- apps/web/src/server/api/routers/reference.ts | 54 +++++++++ packages/db/index.ts | 1 + .../migration.sql | 14 +++ .../migration.sql | 2 + packages/db/prisma/schema.prisma | 15 +++ packages/db/src/services/reference.service.ts | 50 ++++++++ packages/validation/src/index.ts | 11 +- 21 files changed, 454 insertions(+), 114 deletions(-) create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/settings/references/list-references.tsx create mode 100644 apps/web/src/app/(app)/[organizationId]/[projectId]/settings/references/page.tsx create mode 100644 apps/web/src/components/references/table.tsx create mode 100644 apps/web/src/modals/AddReference.tsx create mode 100644 apps/web/src/server/api/routers/reference.ts create mode 100644 packages/db/prisma/migrations/20240306193027_add_reference/migration.sql create mode 100644 packages/db/prisma/migrations/20240306195438_add_date_to_reference/migration.sql create mode 100644 packages/db/src/services/reference.service.ts diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-menu.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-menu.tsx index 939ba33f..6ba50d42 100644 --- a/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-menu.tsx +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/layout-menu.tsx @@ -127,6 +127,11 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) { label="Profile (yours)" href={`/${params.organizationId}/${projectId}/settings/profile`} /> + )} {dashboards.length > 0 && ( diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/references/list-references.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/references/list-references.tsx new file mode 100644 index 00000000..383ccf07 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/references/list-references.tsx @@ -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 ( + <> + +
+
+ +
+ +
+ +
+ + ); +} diff --git a/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/references/page.tsx b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/references/page.tsx new file mode 100644 index 00000000..bdfbaaa4 --- /dev/null +++ b/apps/web/src/app/(app)/[organizationId]/[projectId]/settings/references/page.tsx @@ -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 ( + + + + ); +} diff --git a/apps/web/src/components/references/table.tsx b/apps/web/src/components/references/table.tsx new file mode 100644 index 00000000..ac0ac738 --- /dev/null +++ b/apps/web/src/components/references/table.tsx @@ -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[] = [ + { + accessorKey: 'title', + header: 'Title', + }, + { + accessorKey: 'date', + header: 'Date', + cell({ row }) { + const date = row.original.date; + return
{formatDateTime(date)}
; + }, + }, + { + accessorKey: 'createdAt', + header: 'Created at', + cell({ row }) { + const date = row.original.createdAt; + return
{formatDate(date)}
; + }, + }, +]; diff --git a/apps/web/src/components/report/chart/Chart.tsx b/apps/web/src/components/report/chart/Chart.tsx index 1b1290de..ca56b70a 100644 --- a/apps/web/src/components/report/chart/Chart.tsx +++ b/apps/web/src/components/report/chart/Chart.tsx @@ -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 ( - + ); } diff --git a/apps/web/src/components/report/chart/MetricCard.tsx b/apps/web/src/components/report/chart/MetricCard.tsx index 28082e95..9925db1e 100644 --- a/apps/web/src/components/report/chart/MetricCard.tsx +++ b/apps/web/src/components/report/chart/MetricCard.tsx @@ -59,7 +59,7 @@ export function MetricCard({ className="group relative card p-4 overflow-hidden h-24" key={serie.name} > -
+
{({ width, height }) => ( ref.createdAt.getTime())); + return ( <> {({ width, height }) => ( + {references.map((ref) => ( + + ))} 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 ( diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx index cd3d3a93..966a3175 100644 --- a/apps/web/src/components/ui/popover.tsx +++ b/apps/web/src/components/ui/popover.tsx @@ -10,21 +10,19 @@ const PopoverContent = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, align = 'center', sideOffset = 4, ...props }, ref) => ( - - - + )); PopoverContent.displayName = PopoverPrimitive.Content.displayName; diff --git a/apps/web/src/hooks/useRechartDataModel.ts b/apps/web/src/hooks/useRechartDataModel.ts index 301fa10a..dd443955 100644 --- a/apps/web/src/hooks/useRechartDataModel.ts +++ b/apps/web/src/hooks/useRechartDataModel.ts @@ -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, diff --git a/apps/web/src/modals/AddReference.tsx b/apps/web/src/modals/AddReference.tsx new file mode 100644 index 00000000..44ead4a0 --- /dev/null +++ b/apps/web/src/modals/AddReference.tsx @@ -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; + +export default function AddReference() { + const { projectId } = useAppParams(); + const router = useRouter(); + + const { register, handleSubmit, formState } = useForm({ + 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 ( + + +
mutation.mutate(values))} + > + + + + + + + + +
+ ); +} diff --git a/apps/web/src/modals/index.tsx b/apps/web/src/modals/index.tsx index bb5749f3..febfd5c1 100644 --- a/apps/web/src/modals/index.tsx +++ b/apps/web/src/modals/index.tsx @@ -45,6 +45,9 @@ const modals = { ShareOverviewModal: dynamic(() => import('./ShareOverviewModal'), { loading: Loading, }), + AddReference: dynamic(() => import('./AddReference'), { + loading: Loading, + }), }; const emitter = mitt<{ diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index ea188c05..0b09b4b7 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -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 diff --git a/apps/web/src/server/api/routers/chart.helpers.ts b/apps/web/src/server/api/routers/chart.helpers.ts index 58dee2f9..933b3103 100644 --- a/apps/web/src/server/api/routers/chart.helpers.ts +++ b/apps/web/src/server/api/routers/chart.helpers.ts @@ -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 +) { + return input.startDate && input.endDate + ? { startDate: input.startDate, endDate: input.endDate } + : getDatesFromRange(input.range); +} diff --git a/apps/web/src/server/api/routers/chart.ts b/apps/web/src/server/api/routers/chart.ts index 80d38228..c286e6d1 100644 --- a/apps/web/src/server/api/routers/chart.ts +++ b/apps/web/src/server/api/routers/chart.ts @@ -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(), - }; } diff --git a/apps/web/src/server/api/routers/reference.ts b/apps/web/src/server/api/routers/reference.ts new file mode 100644 index 00000000..4caab84c --- /dev/null +++ b/apps/web/src/server/api/routers/reference.ts @@ -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), + }, + }, + }); + }), +}); diff --git a/packages/db/index.ts b/packages/db/index.ts index f4d14f22..efd5626d 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -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'; diff --git a/packages/db/prisma/migrations/20240306193027_add_reference/migration.sql b/packages/db/prisma/migrations/20240306193027_add_reference/migration.sql new file mode 100644 index 00000000..b57b5a6e --- /dev/null +++ b/packages/db/prisma/migrations/20240306193027_add_reference/migration.sql @@ -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; diff --git a/packages/db/prisma/migrations/20240306195438_add_date_to_reference/migration.sql b/packages/db/prisma/migrations/20240306195438_add_date_to_reference/migration.sql new file mode 100644 index 00000000..7d9eba82 --- /dev/null +++ b/packages/db/prisma/migrations/20240306195438_add_date_to_reference/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "references" ADD COLUMN "date" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 92799467..815e63c5 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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") +} diff --git a/packages/db/src/services/reference.service.ts b/packages/db/src/services/reference.service.ts new file mode 100644 index 00000000..3a8023b3 --- /dev/null +++ b/packages/db/src/services/reference.service.ts @@ -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 & { + 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); +} diff --git a/packages/validation/src/index.ts b/packages/validation/src/index.ts index 9084ebd0..176555f6 100644 --- a/packages/validation/src/index.ts +++ b/packages/validation/src/index.ts @@ -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(), +});