diff --git a/README.md b/README.md index 42e78242..54b94978 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Mixan is a simple analytics tool for logging events on web and react-native. My * [ ] Drag n Drop reports on dashboard * [ ] Manage dashboards * [ ] Support more chart types - * [ ] Bar + * [X] Bar * [ ] Pie * [ ] Area * [ ] Support funnels diff --git a/apps/web/src/components/ColorSquare.tsx b/apps/web/src/components/ColorSquare.tsx new file mode 100644 index 00000000..a111123c --- /dev/null +++ b/apps/web/src/components/ColorSquare.tsx @@ -0,0 +1,17 @@ +import { type HtmlProps } from "@/types"; +import { cn } from "@/utils/cn"; + +type ColorSquareProps = HtmlProps; + +export function ColorSquare({ children, className }: ColorSquareProps) { + return ( +
+ {children} +
+ ); +} diff --git a/apps/web/src/components/report/ReportChartType.tsx b/apps/web/src/components/report/ReportChartType.tsx new file mode 100644 index 00000000..1cee23ee --- /dev/null +++ b/apps/web/src/components/report/ReportChartType.tsx @@ -0,0 +1,33 @@ +import { useDispatch, useSelector } from "@/redux"; +import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; +import { + changeChartType, + changeDateRanges, + changeInterval, +} from "./reportSlice"; +import { Combobox } from "../ui/combobox"; +import { type IChartType } from "@/types"; +import { chartTypes } from "@/utils/constants"; + +export function ReportChartType() { + const dispatch = useDispatch(); + const type = useSelector((state) => state.report.chartType); + + return ( + <> +
+ { + dispatch(changeChartType(value as IChartType)); + }} + value={type} + items={Object.entries(chartTypes).map(([key, value]) => ({ + label: value, + value: key, + }))} + /> +
+ + ); +} diff --git a/apps/web/src/components/report/ReportDateRange.tsx b/apps/web/src/components/report/ReportDateRange.tsx index cf96683c..fc038042 100644 --- a/apps/web/src/components/report/ReportDateRange.tsx +++ b/apps/web/src/components/report/ReportDateRange.tsx @@ -7,13 +7,14 @@ import { type IInterval } from "@/types"; export function ReportDateRange() { const dispatch = useDispatch(); const interval = useSelector((state) => state.report.interval); - + const chartType = useSelector((state) => state.report.chartType); + return ( <> { - dispatch(changeDateRanges('today')); + dispatch(changeDateRanges("today")); }} > Today @@ -68,29 +69,31 @@ export function ReportDateRange() { 1 year -
- { - dispatch(changeInterval(value as IInterval)); - }} - value={interval} - items={[ - { - label: "Hour", - value: "hour", - }, - { - label: "Day", - value: "day", - }, - { - label: "Month", - value: "month", - }, - ]} - > -
+ {chartType === "linear" && ( +
+ { + dispatch(changeInterval(value as IInterval)); + }} + value={interval} + items={[ + { + label: "Hour", + value: "hour", + }, + { + label: "Day", + value: "day", + }, + { + label: "Month", + value: "month", + }, + ]} + /> +
+ )} ); } diff --git a/apps/web/src/components/report/chart/ChartProvider.tsx b/apps/web/src/components/report/chart/ChartProvider.tsx new file mode 100644 index 00000000..917b59af --- /dev/null +++ b/apps/web/src/components/report/chart/ChartProvider.tsx @@ -0,0 +1,47 @@ +import { pick } from "ramda"; +import { createContext, memo, useContext, useMemo } from "react"; + +type ChartContextType = { + editMode: boolean; +}; + +type ChartProviderProps = { + children: React.ReactNode; +} & ChartContextType; + +const ChartContext = createContext({ + editMode: false, +}); + +export function ChartProvider({ children, editMode }: ChartProviderProps) { + return ( + ({ + editMode, + }), + [editMode], + )} + > + {children} + + ); +} + +export function withChartProivder(WrappedComponent: React.FC) { + const WithChartProvider = (props: ComponentProps & ChartContextType) => { + return ( + + + + ) + } + + WithChartProvider.displayName = `WithChartProvider(${WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component'})` + + return memo(WithChartProvider) +} + +export function useChartContext() { + return useContext(ChartContext); +} \ No newline at end of file diff --git a/apps/web/src/components/report/chart/ReportBarChart.tsx b/apps/web/src/components/report/chart/ReportBarChart.tsx new file mode 100644 index 00000000..3986e1d9 --- /dev/null +++ b/apps/web/src/components/report/chart/ReportBarChart.tsx @@ -0,0 +1,191 @@ +import { ColorSquare } from "@/components/ColorSquare"; +import { type IChartData } from "@/types"; +import { type RouterOutputs } from "@/utils/api"; +import { getChartColor } from "@/utils/theme"; +import { + useReactTable, + getCoreRowModel, + flexRender, + createColumnHelper, + getSortedRowModel, + type SortingState, +} from "@tanstack/react-table"; +import { memo, useEffect, useMemo, useState } from "react"; +import { useElementSize } from "usehooks-ts"; +import { useChartContext } from "./ChartProvider"; +import { ChevronDown, ChevronUp } from "lucide-react"; +import { cn } from "@/utils/cn"; + +const columnHelper = + createColumnHelper(); + +type ReportBarChartProps = { + data: IChartData; +}; + +export function ReportBarChart({ data }: ReportBarChartProps) { + const { editMode } = useChartContext(); + const [ref, { width }] = useElementSize(); + const [sorting, setSorting] = useState([]); + const table = useReactTable({ + data: useMemo( + () => (editMode ? data.series : data.series.slice(0, 20)), + [editMode, data], + ), + columns: useMemo(() => { + return [ + columnHelper.accessor((row) => row.name, { + id: "label", + header: () => "Label", + cell(info) { + return ( +
+ {info.row.original.event.id} + {info.getValue()} +
+ ); + }, + footer: (info) => info.column.id, + size: width ? width * 0.3 : undefined, + }), + columnHelper.accessor((row) => row.totalCount, { + id: "totalCount", + cell: (info) => ( +
{info.getValue()}
+ ), + header: () => "Count", + footer: (info) => info.column.id, + size: width ? width * 0.1 : undefined, + enableSorting: true, + }), + columnHelper.accessor((row) => row.totalCount, { + id: "graph", + cell: (info) => ( +
+ ), + header: () => "Graph", + footer: (info) => info.column.id, + size: width ? width * 0.6 : undefined, + }), + ]; + }, [width]), + columnResizeMode: "onChange", + state: { + sorting, + }, + getCoreRowModel: getCoreRowModel(), + onSortingChange: setSorting, + getSortedRowModel: getSortedRowModel(), + debugTable: true, + debugHeaders: true, + debugColumns: true, + }); + return ( +
+ {editMode && ( +
+ {data.events.map((event) => { + return ( +
+
+ {event.id} {event.name} +
+
+ {new Intl.NumberFormat("en-IN", { + maximumSignificantDigits: 20, + }).format(event.count)} +
+
+ ); + })} +
+ )} +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
+
+ {flexRender( + header.column.columnDef.header, + header.getContext(), + )} + {{ + asc: , + desc: , + }[header.column.getIsSorted() as string] ?? null} +
+
+
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+
+ ); +} diff --git a/apps/web/src/components/report/chart/ReportLineChart.tsx b/apps/web/src/components/report/chart/ReportLineChart.tsx index dcd49ce0..77fd4296 100644 --- a/apps/web/src/components/report/chart/ReportLineChart.tsx +++ b/apps/web/src/components/report/chart/ReportLineChart.tsx @@ -1,4 +1,3 @@ -import { api } from "@/utils/api"; import { CartesianGrid, Line, @@ -7,114 +6,86 @@ import { XAxis, YAxis, } from "recharts"; -import { ReportLineChartTooltip } from "./ReportLineChartTooltop"; +import { ReportLineChartTooltip } from "./ReportLineChartTooltip"; import { useFormatDateInterval } from "@/hooks/useFormatDateInterval"; -import { type IChartInput } from "@/types"; +import { type IChartData, type IInterval } from "@/types"; import { getChartColor } from "@/utils/theme"; import { ReportTable } from "./ReportTable"; import { useEffect, useRef, useState } from "react"; import { AutoSizer } from "@/components/AutoSizer"; +import { useChartContext } from "./ChartProvider"; -type ReportLineChartProps = IChartInput & { - showTable?: boolean; +type ReportLineChartProps = { + data: IChartData; + interval: IInterval; }; -export function ReportLineChart({ - interval, - startDate, - endDate, - events, - breakdowns, - showTable, - chartType, - name, -}: ReportLineChartProps) { +export function ReportLineChart({ interval, data }: ReportLineChartProps) { + const { editMode } = useChartContext(); const [visibleSeries, setVisibleSeries] = useState([]); - - const hasEmptyFilters = events.some((event) => event.filters.some((filter) => filter.value.length === 0)); - - const chart = api.chart.chart.useQuery( - { - interval, - chartType, - startDate, - endDate, - events, - breakdowns, - name, - }, - { - enabled: events.length > 0 && !hasEmptyFilters, - }, - ); - const formatDate = useFormatDateInterval(interval); const ref = useRef(false); useEffect(() => { - if (!ref.current && chart.data) { + if (!ref.current && data) { const max = 20; setVisibleSeries( - chart.data?.series?.slice(0, max).map((serie) => serie.name) ?? [], + data?.series?.slice(0, max).map((serie) => serie.name) ?? [], ); // ref.current = true; } - }, [chart.data]); + }, [data]); return ( <> - {chart.isSuccess && chart.data?.series?.[0]?.data && ( - <> - - {({ width }) => ( - - - } /> - - { - return formatDate(m); - }} - tickLine={false} - allowDuplicatedCategory={false} - /> - {chart.data?.series - .filter((serie) => { - return visibleSeries.includes(serie.name); - }) - .map((serie) => { - const realIndex = chart.data?.series.findIndex( - (item) => item.name === serie.name, - ); - const key = serie.name; - const strokeColor = getChartColor(realIndex); - return ( - - ); - })} - - )} - - {showTable && ( - + {({ width }) => ( + + + } /> + + { + return formatDate(m); + }} + tickLine={false} + allowDuplicatedCategory={false} /> - )} - + {data?.series + .filter((serie) => { + return visibleSeries.includes(serie.name); + }) + .map((serie) => { + const realIndex = data?.series.findIndex( + (item) => item.name === serie.name, + ); + const key = serie.name; + const strokeColor = getChartColor(realIndex); + return ( + + ); + })} + + )} + + {editMode && ( + )} ); diff --git a/apps/web/src/components/report/chart/ReportLineChartTooltop.tsx b/apps/web/src/components/report/chart/ReportLineChartTooltip.tsx similarity index 92% rename from apps/web/src/components/report/chart/ReportLineChartTooltop.tsx rename to apps/web/src/components/report/chart/ReportLineChartTooltip.tsx index 3fa06eae..c9d7d8cf 100644 --- a/apps/web/src/components/report/chart/ReportLineChartTooltop.tsx +++ b/apps/web/src/components/report/chart/ReportLineChartTooltip.tsx @@ -1,5 +1,6 @@ import { useMappings } from "@/hooks/useMappings"; import { type IToolTipProps } from "@/types"; +import { formatDate } from "@/utils/date"; type ReportLineChartTooltipProps = IToolTipProps<{ color: string; @@ -29,9 +30,11 @@ export function ReportLineChartTooltip({ const sorted = payload.slice(0).sort((a, b) => b.value - a.value); const visible = sorted.slice(0, limit); const hidden = sorted.slice(limit); + const first = visible[0]!; return (
+ {formatDate(new Date(first.payload.date))} {visible.map((item) => { return (
diff --git a/apps/web/src/components/report/chart/ReportTable.tsx b/apps/web/src/components/report/chart/ReportTable.tsx index fc43f589..91f0de29 100644 --- a/apps/web/src/components/report/chart/ReportTable.tsx +++ b/apps/web/src/components/report/chart/ReportTable.tsx @@ -1,15 +1,15 @@ import * as React from "react"; -import { type RouterOutputs } from "@/utils/api"; import { useFormatDateInterval } from "@/hooks/useFormatDateInterval"; import { useSelector } from "@/redux"; import { Checkbox } from "@/components/ui/checkbox"; import { getChartColor } from "@/utils/theme"; import { cn } from "@/utils/cn"; import { useMappings } from "@/hooks/useMappings"; +import { type IChartData } from "@/types"; type ReportTableProps = { - data: RouterOutputs["chart"]["chart"]; + data: IChartData; visibleSeries: string[]; setVisibleSeries: React.Dispatch>; }; diff --git a/apps/web/src/components/report/chart/index.tsx b/apps/web/src/components/report/chart/index.tsx new file mode 100644 index 00000000..a8233505 --- /dev/null +++ b/apps/web/src/components/report/chart/index.tsx @@ -0,0 +1,64 @@ +import { api } from "@/utils/api"; +import { type IChartInput } from "@/types"; +import { ReportBarChart } from "./ReportBarChart"; +import { ReportLineChart } from "./ReportLineChart"; +import { withChartProivder } from "./ChartProvider"; + +type ReportLineChartProps = IChartInput + +export const Chart = withChartProivder(({ + interval, + startDate, + endDate, + events, + breakdowns, + chartType, + name, +}: ReportLineChartProps) => { + const hasEmptyFilters = events.some((event) => event.filters.some((filter) => filter.value.length === 0)); + const chart = api.chart.chart.useQuery( + { + interval, + chartType, + startDate, + endDate, + events, + breakdowns, + name, + }, + { + keepPreviousData: true, + enabled: events.length > 0 && !hasEmptyFilters, + }, + ); + + const anyData = Boolean(chart.data?.series?.[0]?.data) + + if(chart.isFetching && !anyData) { + return (

Loading...

) + } + + if(chart.isError) { + return (

Error

) + } + + if(!chart.isSuccess) { + return (

Loading...

) + } + + + if(!anyData) { + return (

No data

) + } + + if(chartType === 'bar') { + return + } + + if(chartType === 'linear') { + return + } + + + return

Chart type "{chartType}" is not supported yet.

+}) diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/web/src/components/report/reportSlice.ts index 8e0438be..3948bdf1 100644 --- a/apps/web/src/components/report/reportSlice.ts +++ b/apps/web/src/components/report/reportSlice.ts @@ -3,7 +3,9 @@ import { type IChartBreakdown, type IChartEvent, type IInterval, + type IChartType, } from "@/types"; +import { alphabetIds } from "@/utils/constants"; import { getDaysOldDate } from "@/utils/date"; import { type PayloadAction, createSlice } from "@reduxjs/toolkit"; @@ -13,15 +15,13 @@ type InitialState = IChartInput; const initialState: InitialState = { name: "screen_view", chartType: "linear", - startDate: getDaysOldDate(7), - endDate: new Date(), + startDate: getDaysOldDate(7).toISOString(), + endDate: new Date().toISOString(), interval: "day", breakdowns: [], events: [], }; -const IDS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"] as const; - export const reportSlice = createSlice({ name: "counter", initialState, @@ -29,13 +29,13 @@ export const reportSlice = createSlice({ reset() { return initialState }, - setReport(state, action: PayloadAction) { - return action.payload + setReport(state, action: PayloadAction) { + return action.payload }, // Events addEvent: (state, action: PayloadAction>) => { state.events.push({ - id: IDS[state.events.length]!, + id: alphabetIds[state.events.length]!, ...action.payload, }); }, @@ -64,7 +64,7 @@ export const reportSlice = createSlice({ action: PayloadAction>, ) => { state.breakdowns.push({ - id: IDS[state.breakdowns.length]!, + id: alphabetIds[state.breakdowns.length]!, ...action.payload, }); }, @@ -91,28 +91,35 @@ export const reportSlice = createSlice({ changeInterval: (state, action: PayloadAction) => { state.interval = action.payload; }, + + // Chart type + changeChartType: (state, action: PayloadAction) => { + state.chartType = action.payload; + }, // Date range - changeStartDate: (state, action: PayloadAction) => { + changeStartDate: (state, action: PayloadAction) => { state.startDate = action.payload; }, // Date range - changeEndDate: (state, action: PayloadAction) => { + changeEndDate: (state, action: PayloadAction) => { state.endDate = action.payload; }, changeDateRanges: (state, action: PayloadAction) => { if(action.payload === 'today') { - state.startDate = new Date(); - state.endDate = new Date(); - state.startDate.setHours(0,0,0,0) + const startDate = new Date() + startDate.setHours(0,0,0,0) + + state.startDate = startDate.toISOString(); + state.endDate = new Date().toISOString(); state.interval = 'hour' return state } - state.startDate = getDaysOldDate(action.payload); - state.endDate = new Date(); + state.startDate = getDaysOldDate(action.payload).toISOString(); + state.endDate = new Date().toISOString() if (action.payload === 1) { state.interval = "hour"; @@ -137,6 +144,7 @@ export const { changeBreakdown, changeInterval, changeDateRanges, + changeChartType, } = reportSlice.actions; export default reportSlice.reducer; diff --git a/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx b/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx index ecc80a82..6aeb9398 100644 --- a/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx +++ b/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx @@ -6,6 +6,7 @@ import { type ReportEventMoreProps } from "./ReportEventMore"; import { type IChartBreakdown } from "@/types"; import { ReportBreakdownMore } from "./ReportBreakdownMore"; import { RenderDots } from "@/components/ui/RenderDots"; +import { ColorSquare } from "@/components/ColorSquare"; export function ReportBreakdowns() { const selectedBreakdowns = useSelector((state) => state.report.breakdowns); @@ -36,9 +37,9 @@ export function ReportBreakdowns() { return (
-
+ {index} -
+ { diff --git a/apps/web/src/components/report/sidebar/ReportEventFilters.tsx b/apps/web/src/components/report/sidebar/ReportEventFilters.tsx index 24d70e45..a487ef8f 100644 --- a/apps/web/src/components/report/sidebar/ReportEventFilters.tsx +++ b/apps/web/src/components/report/sidebar/ReportEventFilters.tsx @@ -23,6 +23,7 @@ import { ComboboxMulti } from "@/components/ui/combobox-multi"; import { Dropdown } from "@/components/Dropdown"; import { operators } from "@/utils/constants"; import { useMappings } from "@/hooks/useMappings"; +import { ColorSquare } from "@/components/ColorSquare"; type ReportEventFiltersProps = { event: IChartEvent; @@ -163,9 +164,9 @@ function Filter({ filter, event }: FilterProps) { className="px-4 py-2 shadow-[inset_6px_0_0] shadow-slate-200 first:border-t" >
-
+ -
+
{filter.name}
diff --git a/apps/web/src/components/report/sidebar/ReportEvents.tsx b/apps/web/src/components/report/sidebar/ReportEvents.tsx index 6b0b5a35..f8e72ca7 100644 --- a/apps/web/src/components/report/sidebar/ReportEvents.tsx +++ b/apps/web/src/components/report/sidebar/ReportEvents.tsx @@ -8,6 +8,7 @@ import { ReportEventMore, type ReportEventMoreProps } from "./ReportEventMore"; import { type IChartEvent } from "@/types"; import { Filter, GanttChart, Users } from "lucide-react"; import { Dropdown } from "@/components/Dropdown"; +import { ColorSquare } from "@/components/ColorSquare"; export function ReportEvents() { const [isCreating, setIsCreating] = useState(false); @@ -42,9 +43,9 @@ export function ReportEvents() { return (
-
+ {event.id} -
+ { diff --git a/apps/web/src/modals/AddClient.tsx b/apps/web/src/modals/AddClient.tsx index 508e9281..cdb43608 100644 --- a/apps/web/src/modals/AddClient.tsx +++ b/apps/web/src/modals/AddClient.tsx @@ -123,8 +123,6 @@ export default function CreateProject() { { - console.log("wtf?", value); - field.onChange(value); }} items={ diff --git a/apps/web/src/pages/[organization]/[project]/[dashboard].tsx b/apps/web/src/pages/[organization]/[project]/[dashboard].tsx index b1da1c45..8134ed69 100644 --- a/apps/web/src/pages/[organization]/[project]/[dashboard].tsx +++ b/apps/web/src/pages/[organization]/[project]/[dashboard].tsx @@ -1,12 +1,12 @@ -import { ReportLineChart } from "@/components/report/chart/ReportLineChart"; import { MainLayout } from "@/components/layouts/MainLayout"; import { Container } from "@/components/Container"; import { api } from "@/utils/api"; import Link from "next/link"; import { PageTitle } from "@/components/PageTitle"; import { useOrganizationParams } from "@/hooks/useOrganizationParams"; -import { Suspense } from "react"; +import { Suspense, useMemo } from "react"; import { createServerSideProps } from "@/server/getServerSideProps"; +import { Chart } from "@/components/report/chart"; export const getServerSideProps = createServerSideProps() @@ -19,7 +19,9 @@ export default function Dashboard() { }); const dashboard = query.data?.dashboard ?? null; - const reports = query.data?.reports ?? []; + const reports = useMemo(() => { + return query.data?.reports ?? []; + }, [query]) return ( @@ -38,8 +40,8 @@ export default function Dashboard() { > {report.name} -
- +
+
))} diff --git a/apps/web/src/pages/[organization]/reports/index.tsx b/apps/web/src/pages/[organization]/reports/index.tsx index 5ccfc1df..85a6b6c7 100644 --- a/apps/web/src/pages/[organization]/reports/index.tsx +++ b/apps/web/src/pages/[organization]/reports/index.tsx @@ -1,5 +1,5 @@ import { ReportSidebar } from "@/components/report/sidebar/ReportSidebar"; -import { ReportLineChart } from "@/components/report/chart/ReportLineChart"; +import { Chart } from "@/components/report/chart"; import { useDispatch, useSelector } from "@/redux"; import { MainLayout } from "@/components/layouts/MainLayout"; import { ReportDateRange } from "@/components/report/ReportDateRange"; @@ -9,6 +9,7 @@ import { useReportId } from "@/components/report/hooks/useReportId"; import { api } from "@/utils/api"; import { useRouterBeforeLeave } from "@/hooks/useRouterBeforeLeave"; import { createServerSideProps } from "@/server/getServerSideProps"; +import { ReportChartType } from "@/components/report/ReportChartType"; export const getServerSideProps = createServerSideProps() @@ -27,7 +28,7 @@ export default function Page() { // Set report if reportId exists useEffect(() => { - if(reportId && reportQuery.data) { + if(reportId && reportQuery.data) { dispatch(setReport(reportQuery.data)) } }, [reportId, reportQuery.data, dispatch]) @@ -40,9 +41,10 @@ export default function Page() {
+
- +
); diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx index ee870249..53c41514 100644 --- a/apps/web/src/pages/_app.tsx +++ b/apps/web/src/pages/_app.tsx @@ -27,8 +27,6 @@ const MixanApp: AppType<{ session: Session | null }> = ({ Component, pageProps: { session, ...pageProps }, }) => { - console.log('session',session); - return (
diff --git a/apps/web/src/server/api/routers/chart.ts b/apps/web/src/server/api/routers/chart.ts index ee5fe441..8d6638bc 100644 --- a/apps/web/src/server/api/routers/chart.ts +++ b/apps/web/src/server/api/routers/chart.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; import { db } from "@/server/db"; -import { pipe, sort, uniq } from "ramda"; +import { last, pipe, sort, uniq } from "ramda"; import { toDots } from "@/utils/object"; import { zChartInput } from "@/utils/validation"; import { type IChartInput, type IChartEvent } from "@/types"; @@ -13,15 +13,14 @@ export const config = { }; export const chartRouter = createTRPCRouter({ - events: protectedProcedure - .query(async () => { - const events = await db.event.findMany({ - take: 500, - distinct: ["name"], - }); + events: protectedProcedure.query(async () => { + const events = await db.event.findMany({ + take: 500, + distinct: ["name"], + }); - return events; - }), + return events; + }), properties: protectedProcedure .input(z.object({ event: z.string() }).optional()) @@ -106,12 +105,37 @@ export const chartRouter = createTRPCRouter({ ); } - return { - series: series.sort((a, b) => { + const sorted = [...series].sort((a, b) => { + if (input.chartType === "linear") { 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; - }), + } else { + return b.totalCount - a.totalCount; + } + }); + + const meta = { + highest: sorted[0]?.totalCount ?? 0, + lowest: last(sorted)?.totalCount ?? 0, + }; + + return { + events: Object.entries(series.reduce((acc, item) => { + if(acc[item.event.id]) { + acc[item.event.id] += item.totalCount; + } else { + acc[item.event.id] = item.totalCount; + } + return acc + }, {} as Record)).map(([id, count]) => ({ + count, + ...events.find((event) => event.id === id)!, + })), + series: sorted.map((item) => ({ + ...item, + meta, + })), }; }), }); @@ -169,7 +193,7 @@ async function getChartData({ endDate, }: { event: IChartEvent; -} & Omit) { +} & Omit) { const select = []; const where = []; const groupBy = []; @@ -259,30 +283,34 @@ async function getChartData({ } if (startDate) { - where.push(`"createdAt" >= '${startDate.toISOString()}'`); + where.push(`"createdAt" >= '${startDate}'`); } if (endDate) { - where.push(`"createdAt" <= '${endDate.toISOString()}'`); + where.push(`"createdAt" <= '${endDate}'`); } - const sql = ` - SELECT ${select.join(", ")} - FROM events - WHERE ${where.join(" AND ")} - GROUP BY ${groupBy.join(", ")} - ORDER BY ${orderBy.join(", ")} - `; + const sql = [ + `SELECT ${select.join(", ")}`, + `FROM events`, + `WHERE ${where.join(" AND ")}`, + ]; - const result = await db.$queryRawUnsafe(sql); + if (groupBy.length) { + sql.push(`GROUP BY ${groupBy.join(", ")}`); + } + if (orderBy.length) { + sql.push(`ORDER BY ${orderBy.join(", ")}`); + } + + const result = await db.$queryRawUnsafe(sql.join("\n")); // group by sql label const series = result.reduce( (acc, item) => { // item.label can be null when using breakdowns on a property // that doesn't exist on all events - // fallback on event legend - const label = item.label?.trim() ?? getEventLegend(event); + const label = item.label?.trim() ?? event.id; if (label) { if (acc[label]) { acc[label]?.push(item); @@ -301,18 +329,26 @@ async function getChartData({ return Object.keys(series).map((key) => { const legend = breakdowns.length ? key : getEventLegend(event); const data = series[key] ?? []; + return { name: legend, + event: { + id: event.id, + name: event.name, + }, totalCount: getTotalCount(data), - data: fillEmptySpotsInTimeline(data, interval, startDate, endDate).map( - (item) => { - return { - label: legend, - count: item.count, - date: new Date(item.date).toISOString(), - }; - }, - ), + data: + chartType === "linear" + ? fillEmptySpotsInTimeline(data, interval, startDate, endDate).map( + (item) => { + return { + label: legend, + count: item.count, + date: new Date(item.date).toISOString(), + }; + }, + ) + : [], }; }); } @@ -320,8 +356,8 @@ async function getChartData({ function fillEmptySpotsInTimeline( items: ResultItem[], interval: string, - startDate: Date, - endDate: Date, + startDate: string, + endDate: string, ) { const result = []; const clonedStartDate = new Date(startDate); diff --git a/apps/web/src/server/api/routers/report.ts b/apps/web/src/server/api/routers/report.ts index 2dae9378..594a7e40 100644 --- a/apps/web/src/server/api/routers/report.ts +++ b/apps/web/src/server/api/routers/report.ts @@ -8,21 +8,42 @@ import { type IChartInput, type IChartBreakdown, type IChartEvent, + type IChartEventFilter, } from "@/types"; import { type Report as DbReport } from "@prisma/client"; import { getProjectBySlug } from "@/server/services/project.service"; import { getDashboardBySlug } from "@/server/services/dashboard.service"; +import { alphabetIds } from "@/utils/constants"; -function transform(report: DbReport): IChartInput & { id: string } { +function transformFilter(filter: Partial, index: number): IChartEventFilter { + return { + id: filter.id ?? alphabetIds[index]!, + name: filter.name ?? 'Unknown Filter', + operator: filter.operator ?? 'is', + value: typeof filter.value === 'string' ? [filter.value] : filter.value ?? [], + } +} + +function transformEvent(event: Partial, index: number): IChartEvent { + return { + segment: event.segment ?? 'event', + filters: (event.filters ?? []).map(transformFilter), + id: event.id ?? alphabetIds[index]!, + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + name: event.name || 'Untitled', + } +} + +function transformReport(report: DbReport): IChartInput & { id: string } { return { id: report.id, - events: report.events as IChartEvent[], + events: (report.events as IChartEvent[]).map(transformEvent), breakdowns: report.breakdowns as IChartBreakdown[], - startDate: getDaysOldDate(report.range), - endDate: new Date(), + startDate: getDaysOldDate(report.range).toISOString(), + endDate: new Date().toISOString(), chartType: report.chart_type, interval: report.interval, - name: report.name, + name: report.name || 'Untitled', }; } @@ -40,7 +61,7 @@ export const reportRouter = createTRPCRouter({ id, }, }) - .then(transform); + .then(transformReport); }), list: protectedProcedure .input( @@ -60,7 +81,7 @@ export const reportRouter = createTRPCRouter({ }); return { - reports: reports.map(transform), + reports: reports.map(transformReport), dashboard, } }), @@ -82,7 +103,7 @@ export const reportRouter = createTRPCRouter({ interval: report.interval, breakdowns: report.breakdowns, chart_type: report.chartType, - range: dateDifferanceInDays(report.endDate, report.startDate), + range: dateDifferanceInDays(new Date(report.endDate), new Date(report.startDate)), }, }); }), @@ -108,7 +129,7 @@ export const reportRouter = createTRPCRouter({ interval: report.interval, breakdowns: report.breakdowns, chart_type: report.chartType, - range: dateDifferanceInDays(report.endDate, report.startDate), + range: dateDifferanceInDays(new Date(report.endDate), new Date(report.startDate)), }, }); }), diff --git a/apps/web/src/server/db.ts b/apps/web/src/server/db.ts index 47fa369c..ebdfa0e4 100644 --- a/apps/web/src/server/db.ts +++ b/apps/web/src/server/db.ts @@ -10,7 +10,7 @@ export const db = globalForPrisma.prisma ?? new PrismaClient({ log: - env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"], + ['error'] }); if (env.NODE_ENV !== "production") globalForPrisma.prisma = db; diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index c5b39331..94225f1d 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -1,7 +1,7 @@ @tailwind base; @tailwind components; @tailwind utilities; - + @layer base { :root { --background: 0 0% 100%; @@ -9,63 +9,63 @@ --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; - + --popover: 0 0% 100%; --popover-foreground: 222.2 84% 4.9%; - + --primary: 222.2 47.4% 11.2%; --primary-foreground: 210 40% 98%; - + --secondary: 210 40% 96.1%; --secondary-foreground: 222.2 47.4% 11.2%; - + --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; - + --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; - + --destructive: 0 84.2% 60.2%; --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 222.2 84% 4.9%; - + --radius: 0.5rem; } - + .dark { --background: 222.2 84% 4.9%; --foreground: 210 40% 98%; - + --card: 222.2 84% 4.9%; --card-foreground: 210 40% 98%; - + --popover: 222.2 84% 4.9%; --popover-foreground: 210 40% 98%; - + --primary: 210 40% 98%; --primary-foreground: 222.2 47.4% 11.2%; - + --secondary: 217.2 32.6% 17.5%; --secondary-foreground: 210 40% 98%; - + --muted: 217.2 32.6% 17.5%; --muted-foreground: 215 20.2% 65.1%; - + --accent: 217.2 32.6% 17.5%; --accent-foreground: 210 40% 98%; - + --destructive: 0 62.8% 30.6%; --destructive-foreground: 210 40% 98%; - + --border: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%; --ring: 212.7 26.8% 83.9%; } } - + @layer base { * { @apply border-border; @@ -81,12 +81,86 @@ .h2 { @apply text-xl font-medium; } - + .h3 { @apply text-lg font-medium; } - + .h4 { @apply font-medium; } + + .ellipsis { + @apply overflow-hidden text-ellipsis whitespace-nowrap; + } + + .shine { + background-repeat: no-repeat; + background-position: + -120px -120px, + 0 0; + background-image: linear-gradient( + 0 0, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0.2) 37%, + rgba(255, 255, 255, 0.8) 45%, + rgba(255, 255, 255, 0) 50% + ); + background-size: + 250% 250%, + 100% 100%; + transition: background-position 0s ease; + } + + .shine:hover { + background-position: + 0 0, + 0 0; + transition-duration: 0.5s; + } +} + +table { + @apply w-fit border border-border; +} + +table.mini { + @apply text-xs; +} + +th, +td { + @apply border border-border px-4 py-2; +} + +th { + /* relative is for resizing */ + @apply relative text-left font-medium; +} + +.resizer { + position: absolute; + right: 0; + top: 0; + height: 100%; + width: 5px; + background: rgba(0, 0, 0, 0.1); + cursor: col-resize; + user-select: none; + touch-action: none; +} + +.resizer.isResizing { + @apply bg-black; + opacity: 1; +} + +@media (hover: hover) { + .resizer { + opacity: 0; + } + + *:hover > .resizer { + opacity: 1; + } } diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index c9a11c16..31d15340 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -1,4 +1,5 @@ -import { type zTimeInterval, type zChartBreakdown, type zChartEvent, type zChartInput } from "@/utils/validation"; +import { type RouterOutputs } from "@/utils/api"; +import { type zTimeInterval, type zChartBreakdown, type zChartEvent, type zChartInput, type zChartType } from "@/utils/validation"; import { type Client, type Project } from "@prisma/client"; import { type TooltipProps } from "recharts"; import { type z } from "zod"; @@ -11,14 +12,14 @@ export type IChartEventFilter = IChartEvent['filters'][number] export type IChartEventFilterValue = IChartEvent['filters'][number]['value'][number] export type IChartBreakdown = z.infer export type IInterval = z.infer - +export type IChartType = z.infer +export type IChartData = RouterOutputs["chart"]["chart"]; export type IToolTipProps = Omit, 'payload'> & { payload?: Array } - export type IProject = Project export type IClientWithProject = Client & { project: IProject -} \ No newline at end of file +} diff --git a/apps/web/src/utils/constants.ts b/apps/web/src/utils/constants.ts index 525e45a4..53a7ab56 100644 --- a/apps/web/src/utils/constants.ts +++ b/apps/web/src/utils/constants.ts @@ -1,7 +1,22 @@ - export const operators = { is: "Is", isNot: "Is not", - contains: 'Contains', - doesNotContain: 'Not contains', -} \ No newline at end of file + contains: "Contains", + doesNotContain: "Not contains", +}; + +export const chartTypes = { + linear: "Linear", + bar: "Bar", + pie: "Pie", + metric: "Metric", + area: "Area", +}; + +export const intervals = { + day: "Day", + hour: "Hour", + month: "Month", +}; + +export const alphabetIds = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"] as const; \ No newline at end of file diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts index 59192e9d..401b0984 100644 --- a/apps/web/src/utils/validation.ts +++ b/apps/web/src/utils/validation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { operators } from "./constants"; +import { operators, chartTypes, intervals } from "./constants"; function objectToZodEnums ( obj: Record ): [ K, ...K[] ] { const [ firstKey, ...otherKeys ] = Object.keys( obj ) as K[] @@ -33,14 +33,14 @@ export const zChartBreakdown = z.object({ export const zChartEvents = z.array(zChartEvent); export const zChartBreakdowns = z.array(zChartBreakdown); -export const zChartType = z.enum(["linear", "bar", "pie", "metric", "area"]); +export const zChartType = z.enum(objectToZodEnums(chartTypes)); -export const zTimeInterval = z.enum(["day", "hour", "month"]); +export const zTimeInterval = z.enum(objectToZodEnums(intervals)); export const zChartInput = z.object({ name: z.string(), - startDate: z.date(), - endDate: z.date(), + startDate: z.string(), + endDate: z.string(), chartType: zChartType, interval: zTimeInterval, events: zChartEvents, diff --git a/bun.lockb b/bun.lockb index 4a11c745..b2bea5c7 100755 Binary files a/bun.lockb and b/bun.lockb differ