import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { db } from "@/server/db"; import { map, path, pipe, sort, uniq } from "ramda"; import { toDots } from "@/utils/object"; import { Prisma } from "@prisma/client"; import { zChartBreakdowns, zChartEvents, zChartType, zTimeInterval, } from "@/utils/validation"; import { type IChartBreakdown, type IChartEvent } from "@/types"; type ResultItem = { label: string | null; count: number; date: string; }; function propertyNameToSql(name: string) { if (name.includes(".")) { return name .split(".") .map((item, index) => (index === 0 ? item : `'${item}'`)) .join("->"); } return name; } export const config = { api: { responseLimit: false, }, }; async function getChartData({ chartType, event, breakdowns, interval, startDate, endDate, }: { chartType: string; event: IChartEvent; breakdowns: IChartBreakdown[]; interval: string; startDate: Date; endDate: Date; }) { const select = [`count(*)::int as count`]; const where = []; const groupBy = []; const orderBy = []; switch (chartType) { case "bar": { orderBy.push("count DESC"); break; } case "linear": { select.push(`date_trunc('${interval}', "createdAt") as date`); groupBy.push("date"); orderBy.push("date"); break; } } if (event) { const { name, filters } = event; where.push(`name = '${name}'`); if (filters.length > 0) { filters.forEach((filter) => { const { name, value } = filter; if (name.includes(".")) { where.push(`${propertyNameToSql(name)} = '"${value}"'`); } else { where.push(`${name} = '${value}'`); } }); } } if (breakdowns.length) { const breakdown = breakdowns[0]; if (breakdown) { select.push(`${propertyNameToSql(breakdown.name)} as label`); groupBy.push(`label`); } } else { if (event.name) { select.push(`'${event.name}' as label`); } } if (startDate) { where.push(`"createdAt" >= '${startDate.toISOString()}'`); } if (endDate) { where.push(`"createdAt" <= '${endDate.toISOString()}'`); } const sql = ` SELECT ${select.join(", ")} FROM events WHERE ${where.join(" AND ")} GROUP BY ${groupBy.join(", ")} ORDER BY ${orderBy.join(", ")} `; console.log(sql); const result = await db.$queryRawUnsafe(sql); const series = result.reduce( (acc, item) => { const label = item.label?.trim() ?? event.displayName; if (label) { if (acc[label]) { acc[label]?.push(item); } else { acc[label] = [item]; } } return { ...acc, }; }, {} as Record, ); return Object.keys(series).map((key) => { return { name: breakdowns.length ? key ?? "break a leg" : event.displayName, data: fillEmptySpotsInTimeline( series[key] ?? [], interval, startDate, endDate, ).map((item) => { return { ...item, label: breakdowns.length ? key ?? "break a leg" : event.displayName, date: new Date(item.date).toISOString(), }; }), }; }); } export const chartMetaRouter = createTRPCRouter({ events: protectedProcedure // .input(z.object()) .query(async ({ input }) => { const events = await db.event.findMany({ take: 500, distinct: ["name"], }); return events; }), properties: protectedProcedure .input(z.object({ event: z.string() }).optional()) .query(async ({ input }) => { const events = await db.event.findMany({ take: 500, where: { ...(input?.event ? { name: input.event, } : {}), }, }); const properties = events.reduce((acc, event) => { const properties = event as Record; const dotNotation = toDots(properties); return [...acc, ...Object.keys(dotNotation)]; }, [] as string[]); return pipe( sort((a, b) => a.length - b.length), uniq, )(properties); }), values: protectedProcedure .input(z.object({ event: z.string(), property: z.string() })) .query(async ({ input }) => { const events = await db.event.findMany({ where: { name: input.event, properties: { path: input.property.split(".").slice(1), not: Prisma.DbNull, }, createdAt: { // Take last 30 days gte: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 30), }, }, }); const values = uniq( map(path(input.property.split(".")), events), ) as string[]; return { types: uniq( values.map((value) => Array.isArray(value) ? "array" : typeof value, ), ), values, }; }), chart: protectedProcedure .input( z.object({ startDate: z.date().nullish(), endDate: z.date().nullish(), chartType: zChartType, interval: zTimeInterval, events: zChartEvents, breakdowns: zChartBreakdowns, }), ) .query( async ({ input: { chartType, events, breakdowns, interval, ...input }, }) => { const startDate = input.startDate ?? new Date(); const endDate = input.endDate ?? new Date(); const series: Awaited> = []; for (const event of events) { series.push( ...(await getChartData({ chartType, event, breakdowns, interval, startDate, endDate, })), ); } return { series: series.sort((a, b) => { const sumA = a.data.reduce((acc, item) => acc + item.count, 0); const sumB = b.data.reduce((acc, item) => acc + item.count, 0); return sumB - sumA; }), }; }, ), }); function fillEmptySpotsInTimeline( items: ResultItem[], interval: string, startDate: Date, endDate: Date, ) { const result = []; const currentDate = new Date(startDate); currentDate.setHours(2, 0, 0, 0); const modifiedEndDate = new Date(endDate); modifiedEndDate.setHours(2, 0, 0, 0); while (currentDate.getTime() <= modifiedEndDate.getTime()) { const getYear = (date: Date) => date.getFullYear(); const getMonth = (date: Date) => date.getMonth(); const getDay = (date: Date) => date.getDate(); const getHour = (date: Date) => date.getHours(); const getMinute = (date: Date) => date.getMinutes(); const item = items.find((item) => { const date = new Date(item.date); if (interval === "month") { return ( getYear(date) === getYear(currentDate) && getMonth(date) === getMonth(currentDate) ); } if (interval === "day") { return ( getYear(date) === getYear(currentDate) && getMonth(date) === getMonth(currentDate) && getDay(date) === getDay(currentDate) ); } if (interval === "hour") { return ( getYear(date) === getYear(currentDate) && getMonth(date) === getMonth(currentDate) && getDay(date) === getDay(currentDate) && getHour(date) === getHour(currentDate) ); } if (interval === "minute") { return ( getYear(date) === getYear(currentDate) && getMonth(date) === getMonth(currentDate) && getDay(date) === getDay(currentDate) && getHour(date) === getHour(currentDate) && getMinute(date) === getMinute(currentDate) ); } }); if (item) { result.push(item); } else { result.push({ date: currentDate.toISOString(), count: 0, label: null, }); } switch (interval) { case "day": { currentDate.setDate(currentDate.getDate() + 1); break; } case "hour": { currentDate.setHours(currentDate.getHours() + 1); break; } case "minute": { currentDate.setMinutes(currentDate.getMinutes() + 1); break; } case "month": { currentDate.setMonth(currentDate.getMonth() + 1); break; } } } return sort(function (a, b) { return new Date(a.date).getTime() - new Date(b.date).getTime(); }, result); }