diff --git a/apps/web/package.json b/apps/web/package.json index 8aa78d5f..bedcd065 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,8 @@ "@mixan/types": "^0.0.2-alpha", "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^5.1.1", + "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-checkbox": "^1.0.4", "@radix-ui/react-dialog": "^1.0.5", "@radix-ui/react-dropdown-menu": "^2.0.6", @@ -38,10 +40,12 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-redux": "^8.1.3", + "react-virtualized-auto-sizer": "^1.0.20", "recharts": "^2.8.0", "superjson": "^1.13.1", "tailwind-merge": "^1.14.0", "tailwindcss-animate": "^1.0.7", + "usehooks-ts": "^2.9.1", "zod": "^3.22.4" }, "devDependencies": { diff --git a/apps/web/src/components/AutoSizer.tsx b/apps/web/src/components/AutoSizer.tsx new file mode 100644 index 00000000..68cd692a --- /dev/null +++ b/apps/web/src/components/AutoSizer.tsx @@ -0,0 +1,3 @@ +import AutoSizer from "react-virtualized-auto-sizer"; + +export { AutoSizer } diff --git a/apps/web/src/components/report/chart/ReportLineChart.tsx b/apps/web/src/components/report/chart/ReportLineChart.tsx index bf20dc1f..35dd5a8d 100644 --- a/apps/web/src/components/report/chart/ReportLineChart.tsx +++ b/apps/web/src/components/report/chart/ReportLineChart.tsx @@ -17,6 +17,17 @@ import { } from "@/types"; import { getChartColor } from "@/utils/theme"; import { ReportTable } from "./ReportTable"; +import { useEffect, useRef, useState } from "react"; +import { AutoSizer } from "@/components/AutoSizer"; + +type ReportLineChartProps = { + interval: IInterval; + startDate: Date; + endDate: Date; + events: IChartEvent[]; + breakdowns: IChartBreakdown[]; + showTable?: boolean; +}; export function ReportLineChart({ interval, @@ -24,13 +35,9 @@ export function ReportLineChart({ endDate, events, breakdowns, -}: { - interval: IInterval; - startDate: Date; - endDate: Date; - events: IChartEvent[]; - breakdowns: IChartBreakdown[]; -}) { + showTable, +}: ReportLineChartProps) { + const [visibleSeries, setVisibleSeries] = useState([]); const chart = api.chartMeta.chart.useQuery( { interval, @@ -47,42 +54,71 @@ export function ReportLineChart({ const formatDate = useFormatDateInterval(interval); - return ( - <> + const ref = useRef(false); + useEffect(() => { + if (!ref.current && chart.data) { + const max = 20; + + setVisibleSeries( + chart.data?.series?.slice(0, max).map((serie) => serie.name) ?? [], + ); + // ref.current = true; + } + }, [chart.data]); + + return ( + <> {chart.isSuccess && chart.data?.series?.[0]?.data && ( <> - - - - } /> - {/* */} - - { - return formatDate(m); - }} - tickLine={false} - allowDuplicatedCategory={false} - /> - {chart.data?.series.slice(0, 5).map((serie, index) => { - const key = serie.name; - const strokeColor = getChartColor(index) - return ( - - ); - })} - - + + {({ 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 && ( + + )} )} diff --git a/apps/web/src/components/report/chart/ReportTable.tsx b/apps/web/src/components/report/chart/ReportTable.tsx index 878e6e12..cfdf5708 100644 --- a/apps/web/src/components/report/chart/ReportTable.tsx +++ b/apps/web/src/components/report/chart/ReportTable.tsx @@ -4,48 +4,81 @@ 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"; + +type ReportTableProps = { + data: RouterOutputs["chartMeta"]["chart"]; + visibleSeries: string[]; + setVisibleSeries: React.Dispatch>; +}; export function ReportTable({ data, -}: { - data: RouterOutputs["chartMeta"]["chart"]; -}) { + visibleSeries, + setVisibleSeries, +}: ReportTableProps) { const interval = useSelector((state) => state.report.interval); const formatDate = useFormatDateInterval(interval); + function handleChange(name: string, checked: boolean) { + setVisibleSeries((prev) => { + if (checked) { + return [...prev, name]; + } else { + return prev.filter((item) => item !== name); + } + }); + } + + const row = "flex border-b border-border last:border-b-0 flex-1"; + const cell = "p-2 last:pr-8 last:w-[8rem]"; + const value = "min-w-[6rem] text-right"; + const header = "text-sm font-medium"; + const total = 'bg-gray-50 text-emerald-600 font-bold border-r border-border' return ( -
+
{/* Labels */} -
-
Name
+
+
Name
{data.series.map((serie, index) => { - const checked = index < 5; + const checked = visibleSeries.includes(serie.name); + return (
+ handleChange(serie.name, !!checked) + } + style={ + checked + ? { + background: getChartColor(index), + borderColor: getChartColor(index), + } + : undefined + } checked={checked} /> - {serie.name} +
+ {serie.name} +
); })}
{/* ScrollView for all values */} -
+
{/* Header */} -
- {data.series[0]?.data.map((serie, index) => ( +
+
Total
+ {data.series[0]?.data.map((serie) => (
{formatDate(serie.date)}
@@ -53,12 +86,13 @@ export function ReportTable({
{/* Values */} - {data.series.map((serie, index) => { + {data.series.map((serie) => { return ( -
+
+
{serie.totalCount}
{serie.data.map((item) => { return ( -
+
{item.count}
); diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/web/src/components/report/reportSlice.ts index 43acab8d..0881328f 100644 --- a/apps/web/src/components/report/reportSlice.ts +++ b/apps/web/src/components/report/reportSlice.ts @@ -3,6 +3,7 @@ import { type IChartEvent, type IInterval, } from "@/types"; +import { getDaysOldDate } from "@/utils/date"; import { type PayloadAction, createSlice } from "@reduxjs/toolkit"; type InitialState = { @@ -15,27 +16,20 @@ type InitialState = { // First approach: define the initial state using that type const initialState: InitialState = { - startDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7), + startDate: getDaysOldDate(7), endDate: new Date(), interval: "day", breakdowns: [ { id: "A", - name: "properties.params.title", - }, + name: 'properties.id' + } ], events: [ { id: "A", - displayName: "screen_view (0)", - name: "screen_view", - filters: [ - { - id: "1", - name: "properties.route", - value: "RecipeDetails", - }, - ], + name: "sign_up", + filters: [] }, ], }; @@ -115,6 +109,18 @@ export const reportSlice = createSlice({ changeEndDate: (state, action: PayloadAction) => { state.endDate = action.payload; }, + + changeDateRanges: (state, action: PayloadAction) => { + if(action.payload === 1) { + state.interval = "hour"; + } else if(action.payload <= 30) { + state.interval = "day"; + } else { + state.interval = "month"; + } + state.startDate = getDaysOldDate(action.payload); + state.endDate = new Date(); + } }, }); @@ -127,6 +133,7 @@ export const { removeBreakdown, changeBreakdown, changeInterval, + changeDateRanges, } = reportSlice.actions; export default reportSlice.reducer; diff --git a/apps/web/src/components/report/sidebar/ReportEventFilters.tsx b/apps/web/src/components/report/sidebar/ReportEventFilters.tsx index 2aac0722..b5c94edc 100644 --- a/apps/web/src/components/report/sidebar/ReportEventFilters.tsx +++ b/apps/web/src/components/report/sidebar/ReportEventFilters.tsx @@ -141,7 +141,7 @@ function Filter({ filter, event }: FilterProps) {
- {filter.name} + {filter.name} diff --git a/apps/web/src/components/report/sidebar/ReportEvents.tsx b/apps/web/src/components/report/sidebar/ReportEvents.tsx index fb4971cd..d0fdecf9 100644 --- a/apps/web/src/components/report/sidebar/ReportEvents.tsx +++ b/apps/web/src/components/report/sidebar/ReportEvents.tsx @@ -36,11 +36,11 @@ export function ReportEvents() {

Events

- {selectedEvents.map((event, index) => { + {selectedEvents.map((event) => { return (
-
{index}
+
{event.id}
{ @@ -70,7 +70,6 @@ export function ReportEvents() { onChange={(value) => { dispatch( addEvent({ - displayName: `${value} (${selectedEvents.length})`, name: value, filters: [], }), diff --git a/apps/web/src/components/ui/aspect-ratio.tsx b/apps/web/src/components/ui/aspect-ratio.tsx new file mode 100644 index 00000000..c4abbf37 --- /dev/null +++ b/apps/web/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx new file mode 100644 index 00000000..57c67b5b --- /dev/null +++ b/apps/web/src/components/ui/avatar.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/utils/cn" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 4d577cff..041f8495 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -6,8 +6,15 @@ import { ReportLineChart } from "@/components/report/chart/ReportLineChart"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Combobox } from "@/components/ui/combobox"; import { useDispatch, useSelector } from "@/redux"; -import { changeInterval } from "@/components/report/reportSlice"; +import { + changeDateRanges, + changeInterval, +} from "@/components/report/reportSlice"; import { type IInterval } from "@/types"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuShortcut, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { User } from "lucide-react"; +import { DropdownMenuSeparator } from "@radix-ui/react-dropdown-menu"; export default function Home() { const dispatch = useDispatch(); @@ -24,6 +31,50 @@ export default function Home() { +
@@ -32,12 +83,55 @@ export default function Home() {
- 7 days - 14 days - 1 month - 3 month - 6 month - 1 year + { + dispatch(changeDateRanges(1)); + }} + > + Today + + { + dispatch(changeDateRanges(1)); + }} + > + 7 days + + { + dispatch(changeDateRanges(14)); + }} + > + 14 days + + { + dispatch(changeDateRanges(30)); + }} + > + 1 month + + { + dispatch(changeDateRanges(90)); + }} + > + 3 month + + { + dispatch(changeDateRanges(180)); + }} + > + 6 month + + { + dispatch(changeDateRanges(356)); + }} + > + 1 year +
)}
diff --git a/apps/web/src/server/api/routers/chartMeta.ts b/apps/web/src/server/api/routers/chartMeta.ts index c49a7672..c94b49a8 100644 --- a/apps/web/src/server/api/routers/chartMeta.ts +++ b/apps/web/src/server/api/routers/chartMeta.ts @@ -30,6 +30,14 @@ function propertyNameToSql(name: string) { return name; } +function getEventLegend(event: IChartEvent) { + return `${event.name} (${event.id})` +} + +function getTotalCount(arr: ResultItem[]) { + return arr.reduce((acc, item) => acc + item.count, 0); +} + export const config = { api: { responseLimit: false, @@ -114,9 +122,14 @@ async function getChartData({ console.log(sql); const result = await db.$queryRawUnsafe(sql); + + // group by sql label const series = result.reduce( (acc, item) => { - const label = item.label?.trim() ?? event.displayName; + // 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) if (label) { if (acc[label]) { acc[label]?.push(item); @@ -133,17 +146,20 @@ async function getChartData({ ); return Object.keys(series).map((key) => { + const legend = breakdowns.length ? key : getEventLegend(event); + const data = series[key] ?? [] return { - name: breakdowns.length ? key ?? "break a leg" : event.displayName, + name: legend, + totalCount: getTotalCount(data), data: fillEmptySpotsInTimeline( - series[key] ?? [], + data, interval, startDate, endDate, ).map((item) => { return { - ...item, - label: breakdowns.length ? key ?? "break a leg" : event.displayName, + label: legend, + count: item.count, date: new Date(item.date).toISOString(), }; }), diff --git a/apps/web/src/utils/date.ts b/apps/web/src/utils/date.ts index e69de29b..8cd4c276 100644 --- a/apps/web/src/utils/date.ts +++ b/apps/web/src/utils/date.ts @@ -0,0 +1,5 @@ +export function getDaysOldDate(days: number) { + const date = new Date(); + date.setDate(date.getDate() - days); + return date; +} \ No newline at end of file diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts index cb14885e..a2f954e2 100644 --- a/apps/web/src/utils/validation.ts +++ b/apps/web/src/utils/validation.ts @@ -3,7 +3,6 @@ import { z } from "zod"; export const zChartEvent = z.object({ id: z.string(), name: z.string(), - displayName: z.string(), filters: z.array( z.object({ id: z.string(), diff --git a/bun.lockb b/bun.lockb index 54c4f369..df765780 100755 Binary files a/bun.lockb and b/bun.lockb differ