diff --git a/apps/web/prisma/migrations/20231101143637_add_minute_to_interval/migration.sql b/apps/web/prisma/migrations/20231101143637_add_minute_to_interval/migration.sql new file mode 100644 index 00000000..88d368fc --- /dev/null +++ b/apps/web/prisma/migrations/20231101143637_add_minute_to_interval/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Interval" ADD VALUE 'minute'; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index 57a1366e..b606809d 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -109,6 +109,7 @@ enum Interval { hour day month + minute } enum ChartType { diff --git a/apps/web/src/components/Pagination.tsx b/apps/web/src/components/Pagination.tsx index e38809d6..fed6e2b2 100644 --- a/apps/web/src/components/Pagination.tsx +++ b/apps/web/src/components/Pagination.tsx @@ -16,7 +16,9 @@ export function usePagination(take = 100) { ); } -export function Pagination(props: ReturnType) { +export type PaginationProps = ReturnType + +export function Pagination(props: PaginationProps) { return (
)} diff --git a/apps/web/src/components/report/chart/ReportLineChartTooltip.tsx b/apps/web/src/components/report/chart/ReportLineChartTooltip.tsx index c9d7d8cf..7b6c4b99 100644 --- a/apps/web/src/components/report/chart/ReportLineChartTooltip.tsx +++ b/apps/web/src/components/report/chart/ReportLineChartTooltip.tsx @@ -1,6 +1,7 @@ +import { useFormatDateInterval } from "@/hooks/useFormatDateInterval"; import { useMappings } from "@/hooks/useMappings"; +import { useSelector } from "@/redux"; import { type IToolTipProps } from "@/types"; -import { formatDate } from "@/utils/date"; type ReportLineChartTooltipProps = IToolTipProps<{ color: string; @@ -17,6 +18,8 @@ export function ReportLineChartTooltip({ payload, }: ReportLineChartTooltipProps) { const getLabel = useMappings(); + const interval = useSelector((state) => state.report.interval); + const formatDate = useFormatDateInterval(interval); if (!active || !payload) { return null; diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/web/src/components/report/reportSlice.ts index 668949af..2fc90075 100644 --- a/apps/web/src/components/report/reportSlice.ts +++ b/apps/web/src/components/report/reportSlice.ts @@ -117,7 +117,9 @@ export const reportSlice = createSlice({ changeDateRanges: (state, action: PayloadAction) => { state.range = action.payload - if (action.payload === 0 || action.payload === 1) { + if (action.payload === 0.3 || action.payload === 0.6) { + state.interval = "minute"; + } else if (action.payload === 0 || action.payload === 1) { state.interval = "hour"; } else if (action.payload <= 30) { state.interval = "day"; diff --git a/apps/web/src/hooks/useFormatDateInterval.ts b/apps/web/src/hooks/useFormatDateInterval.ts index 07729e0c..75b013cb 100644 --- a/apps/web/src/hooks/useFormatDateInterval.ts +++ b/apps/web/src/hooks/useFormatDateInterval.ts @@ -2,7 +2,7 @@ import { type IInterval } from "@/types"; export function formatDateInterval(interval: IInterval, date: Date): string { - if (interval === "hour") { + if (interval === "hour" || interval === "minute") { return new Intl.DateTimeFormat("en-GB", { hour: "2-digit", minute: "2-digit", @@ -14,11 +14,11 @@ export function formatDateInterval(interval: IInterval, date: Date): string { } if (interval === "day") { - return new Intl.DateTimeFormat("en-GB", { weekday: "short" }).format( + return new Intl.DateTimeFormat("en-GB", { weekday: "short", day: '2-digit', month: '2-digit' }).format( date, ); } - + return date.toISOString(); } diff --git a/apps/web/src/hooks/useOrganizationParams.ts b/apps/web/src/hooks/useOrganizationParams.ts index adc17548..236a2ae3 100644 --- a/apps/web/src/hooks/useOrganizationParams.ts +++ b/apps/web/src/hooks/useOrganizationParams.ts @@ -7,6 +7,7 @@ export function useOrganizationParams() { organization: z.string(), project: z.string(), dashboard: z.string(), + profileId: z.string().optional(), }), ); } \ No newline at end of file diff --git a/apps/web/src/pages/[organization]/[project]/events.tsx b/apps/web/src/pages/[organization]/[project]/events.tsx index b02ed554..af5ed847 100644 --- a/apps/web/src/pages/[organization]/[project]/events.tsx +++ b/apps/web/src/pages/[organization]/[project]/events.tsx @@ -1,20 +1,12 @@ import { Container } from "@/components/Container"; -import { DataTable } from "@/components/DataTable"; import { PageTitle } from "@/components/PageTitle"; -import { Pagination, usePagination } from "@/components/Pagination"; +import { usePagination } from "@/components/Pagination"; +import { EventsTable } from "@/components/events/EventsTable"; import { MainLayout } from "@/components/layouts/MainLayout"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; import { useOrganizationParams } from "@/hooks/useOrganizationParams"; -import { type RouterOutputs, api } from "@/utils/api"; -import { formatDateTime } from "@/utils/date"; -import { toDots } from "@/utils/object"; -import { AvatarImage } from "@radix-ui/react-avatar"; -import { createColumnHelper } from "@tanstack/react-table"; -import { useMemo } from "react"; +import { api } from "@/utils/api"; -const columnHelper = - createColumnHelper(); +import { useMemo } from "react"; export default function Events() { const pagination = usePagination(); @@ -29,80 +21,12 @@ export default function Events() { }, ); const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]); - const columns = useMemo(() => { - return [ - columnHelper.accessor((row) => row.createdAt, { - id: "createdAt", - header: () => "Created At", - cell(info) { - return formatDateTime(info.getValue()); - }, - footer: () => "Created At", - }), - columnHelper.accessor((row) => row.name, { - id: "event", - header: () => "Event", - cell(info) { - return {info.getValue()}; - }, - footer: () => "Created At", - }), - columnHelper.accessor((row) => row.profile, { - id: "profile", - header: () => "Profile", - cell(info) { - const profile = info.getValue(); - return ( -
- - {profile?.avatar && } - - {profile?.first_name?.at(0)} - - - {`${profile?.first_name} ${profile?.last_name ?? ""}`} -
- ); - }, - footer: () => "Created At", - }), - columnHelper.accessor((row) => row.properties, { - id: "properties", - header: () => "Properties", - cell(info) { - const dots = toDots(info.getValue() as Record); - return ( - - - {Object.keys(dots).map((key) => { - return ( - - {key} - - {typeof dots[key] === "boolean" - ? dots[key] - ? "true" - : "false" - : dots[key]} - - - ); - })} - -
- ); - }, - footer: () => "Created At", - }), - ]; - }, []); + return ( Events - - - + ); diff --git a/apps/web/src/pages/[organization]/[project]/profiles/[profileId].tsx b/apps/web/src/pages/[organization]/[project]/profiles/[profileId].tsx new file mode 100644 index 00000000..8fceb5d0 --- /dev/null +++ b/apps/web/src/pages/[organization]/[project]/profiles/[profileId].tsx @@ -0,0 +1,41 @@ +import { Container } from "@/components/Container"; +import { PageTitle } from "@/components/PageTitle"; +import { usePagination } from "@/components/Pagination"; +import { EventsTable } from "@/components/events/EventsTable"; +import { MainLayout } from "@/components/layouts/MainLayout"; +import { useOrganizationParams } from "@/hooks/useOrganizationParams"; +import { useQueryParams } from "@/hooks/useQueryParams"; +import { api } from "@/utils/api"; + +import { useMemo } from "react"; +import { z } from "zod"; + +export default function ProfileId() { + const pagination = usePagination(); + const params = useOrganizationParams(); + const { profileId } = useQueryParams( + z.object({ + profileId: z.string(), + }), + ); + const eventsQuery = api.event.list.useQuery( + { + projectSlug: params.project, + profileId, + ...pagination, + }, + { + keepPreviousData: true, + }, + ); + const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]); + + return ( + + + Profile + + + + ); +} diff --git a/apps/web/src/pages/[organization]/[project]/profiles/index.tsx b/apps/web/src/pages/[organization]/[project]/profiles/index.tsx new file mode 100644 index 00000000..6cce1f19 --- /dev/null +++ b/apps/web/src/pages/[organization]/[project]/profiles/index.tsx @@ -0,0 +1,100 @@ +import { Container } from "@/components/Container"; +import { DataTable } from "@/components/DataTable"; +import { PageTitle } from "@/components/PageTitle"; +import { Pagination, usePagination } from "@/components/Pagination"; +import { MainLayout } from "@/components/layouts/MainLayout"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table"; +import { useOrganizationParams } from "@/hooks/useOrganizationParams"; +import { type RouterOutputs, api } from "@/utils/api"; +import { formatDateTime } from "@/utils/date"; +import { toDots } from "@/utils/object"; +import { AvatarImage } from "@radix-ui/react-avatar"; +import { createColumnHelper } from "@tanstack/react-table"; +import Link from "next/link"; +import { useMemo } from "react"; + +const columnHelper = + createColumnHelper(); + +export default function Events() { + const pagination = usePagination(); + const params = useOrganizationParams(); + const eventsQuery = api.profile.list.useQuery( + { + projectSlug: params.project, + ...pagination, + }, + { + keepPreviousData: true, + }, + ); + const profiles = useMemo(() => eventsQuery.data ?? [], [eventsQuery]); + const columns = useMemo(() => { + return [ + columnHelper.accessor((row) => row.createdAt, { + id: "createdAt", + header: () => "Created At", + cell(info) { + return formatDateTime(info.getValue()); + }, + }), + columnHelper.accessor('first_name', { + id: "name", + header: () => "Name", + cell(info) { + const profile = info.row.original; + return ( + + + {profile?.avatar && } + + {profile?.first_name?.at(0)} + + + {`${profile?.first_name} ${profile?.last_name ?? ""}`} + + ); + }, + }), + columnHelper.accessor((row) => row.properties, { + id: "properties", + header: () => "Properties", + cell(info) { + const dots = toDots(info.getValue() as Record); + if(Object.keys(dots).length === 0) return 'No properties'; + return ( + + + {Object.keys(dots).map((key) => { + return ( + + {key} + + {typeof dots[key] === "boolean" + ? dots[key] + ? "true" + : "false" + : dots[key]} + + + ); + })} + +
+ ); + }, + }), + ]; + }, []); + return ( + + + Profiles + + + + + + ); +} diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index d93cea36..379eb177 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -7,6 +7,7 @@ import { projectRouter } from "./routers/project"; import { clientRouter } from "./routers/client"; import { dashboardRouter } from "./routers/dashboard"; import { eventRouter } from "./routers/event"; +import { profileRouter } from "./routers/profile"; /** * This is the primary router for your server. @@ -22,6 +23,7 @@ export const appRouter = createTRPCRouter({ project: projectRouter, client: clientRouter, event: eventRouter, + profile: profileRouter, }); // export type definition of API diff --git a/apps/web/src/server/api/routers/chart.ts b/apps/web/src/server/api/routers/chart.ts index 3b553f73..7276a998 100644 --- a/apps/web/src/server/api/routers/chart.ts +++ b/apps/web/src/server/api/routers/chart.ts @@ -190,6 +190,10 @@ function getTotalCount(arr: ResultItem[]) { return arr.reduce((acc, item) => acc + item.count, 0); } +function isFloat(n: number) { + return n % 1 !== 0; +} + function getDatesFromRange(range: IChartRange) { if (range === 0) { const startDate = new Date(); @@ -202,6 +206,16 @@ function getDatesFromRange(range: IChartRange) { }; } + if (isFloat(range)) { + const startDate = new Date(Date.now() - 1000 * 60 * (range * 100)); + const endDate = new Date().toISOString(); + + return { + startDate: startDate.toISOString(), + endDate: endDate, + }; + } + const startDate = getDaysOldDate(range).toISOString(); const endDate = new Date().toISOString(); return { @@ -210,25 +224,7 @@ function getDatesFromRange(range: IChartRange) { }; } -async function getChartData({ - chartType, - event, - breakdowns, - interval, - range, - startDate: _startDate, - endDate: _endDate, -}: { - event: IChartEvent; -} & Omit) { - const { startDate, endDate } = - _startDate && _endDate - ? { - startDate: _startDate, - endDate: _endDate, - } - : getDatesFromRange(range); - +function getChartSql({ event, chartType, breakdowns, interval, startDate, endDate }: Omit) { const select = []; const where = []; const groupBy = []; @@ -338,7 +334,54 @@ async function getChartData({ sql.push(`ORDER BY ${orderBy.join(", ")}`); } - const result = await db.$queryRawUnsafe(sql.join("\n")); + return sql.join("\n"); +} + +type IGetChartDataInput = { + event: IChartEvent; +} & Omit + +async function getChartData({ + chartType, + event, + breakdowns, + interval, + range, + startDate: _startDate, + endDate: _endDate, +}: IGetChartDataInput) { + const { startDate, endDate } = + _startDate && _endDate + ? { + startDate: _startDate, + endDate: _endDate, + } + : getDatesFromRange(range); + + const sql = getChartSql({ + chartType, + event, + breakdowns, + interval, + startDate, + endDate, + }) + + let result = await db.$queryRawUnsafe(sql); + + if(result.length === 0 && breakdowns.length > 0) { + result = await db.$queryRawUnsafe(getChartSql({ + chartType, + event, + breakdowns: [], + interval, + startDate, + endDate, + })); + } + + console.log(sql); + // group by sql label const series = result.reduce( @@ -399,7 +442,10 @@ function fillEmptySpotsInTimeline( const clonedEndDate = new Date(endDate); const today = new Date(); - if (interval === "hour") { + if(interval === 'minute') {  + clonedStartDate.setSeconds(0, 0) + clonedEndDate.setMinutes(clonedEndDate.getMinutes() + 1, 0, 0); + } else if (interval === "hour") { clonedStartDate.setMinutes(0, 0, 0); clonedEndDate.setMinutes(0, 0, 0); } else { diff --git a/apps/web/src/server/api/routers/event.ts b/apps/web/src/server/api/routers/event.ts index c156f010..dc60401d 100644 --- a/apps/web/src/server/api/routers/event.ts +++ b/apps/web/src/server/api/routers/event.ts @@ -12,13 +12,13 @@ export const eventRouter = createTRPCRouter({ list: protectedProcedure .input( z.object({ - cursor: z.string().optional(), projectSlug: z.string(), take: z.number().default(100), skip: z.number().default(0), + profileId: z.string().optional(), }), ) - .query(async ({ input: { take, skip, projectSlug } }) => { + .query(async ({ input: { take, skip, projectSlug, profileId } }) => { const project = await db.project.findUniqueOrThrow({ where: { slug: projectSlug, @@ -29,6 +29,7 @@ export const eventRouter = createTRPCRouter({ skip, where: { project_id: project.id, + profile_id: profileId }, orderBy: { createdAt: "desc", diff --git a/apps/web/src/server/api/routers/profile.ts b/apps/web/src/server/api/routers/profile.ts new file mode 100644 index 00000000..19220adb --- /dev/null +++ b/apps/web/src/server/api/routers/profile.ts @@ -0,0 +1,37 @@ +import { z } from "zod"; +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +import { db } from "@/server/db"; + +export const config = { + api: { + responseLimit: false, + }, +}; + +export const profileRouter = createTRPCRouter({ + list: protectedProcedure + .input( + z.object({ + projectSlug: z.string(), + take: z.number().default(100), + skip: z.number().default(0), + }), + ) + .query(async ({ input: { take, skip, projectSlug } }) => { + const project = await db.project.findUniqueOrThrow({ + where: { + slug: projectSlug, + }, + }); + return db.profile.findMany({ + take, + skip, + where: { + project_id: project.id, + }, + orderBy: { + createdAt: "desc", + }, + }); + }), +}); diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index b411aa86..13d3cf56 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -14,6 +14,7 @@ export const chartTypes = { }; export const intervals = { + minute: "Minute", day: "Day", hour: "Hour", month: "Month", @@ -33,12 +34,14 @@ export const alphabetIds = [ ] as const; export const timeRanges = [ + { range: 0.3, title: "30m" }, + { range: 0.6, title: "1h" }, { range: 0, title: "Today" }, - { range: 1, title: "24 hours" }, - { range: 7, title: "7 days" }, - { range: 14, title: "14 days" }, - { range: 30, title: "30 days" }, - { range: 90, title: "3 months" }, - { range: 180, title: "6 months" }, - { range: 365, title: "1 year" }, -] as const + { range: 1, title: "24h" }, + { range: 7, title: "7d" }, + { range: 14, title: "14d" }, + { range: 30, title: "30d" }, + { range: 90, title: "3mo" }, + { range: 180, title: "6mo" }, + { range: 365, title: "1y" }, +] as const; diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts index 55d9c1de..87aafc2d 100644 --- a/apps/web/src/utils/validation.ts +++ b/apps/web/src/utils/validation.ts @@ -39,6 +39,8 @@ export const zChartInput = z.object({ breakdowns: zChartBreakdowns, range: z .literal(0) + .or(z.literal(0.3)) + .or(z.literal(0.6)) .or(z.literal(1)) .or(z.literal(7)) .or(z.literal(14))