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