improve graphs and table
This commit is contained in:
@@ -14,6 +14,8 @@
|
|||||||
"@mixan/types": "^0.0.2-alpha",
|
"@mixan/types": "^0.0.2-alpha",
|
||||||
"@next-auth/prisma-adapter": "^1.0.7",
|
"@next-auth/prisma-adapter": "^1.0.7",
|
||||||
"@prisma/client": "^5.1.1",
|
"@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-checkbox": "^1.0.4",
|
||||||
"@radix-ui/react-dialog": "^1.0.5",
|
"@radix-ui/react-dialog": "^1.0.5",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
"@radix-ui/react-dropdown-menu": "^2.0.6",
|
||||||
@@ -38,10 +40,12 @@
|
|||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"react-redux": "^8.1.3",
|
"react-redux": "^8.1.3",
|
||||||
|
"react-virtualized-auto-sizer": "^1.0.20",
|
||||||
"recharts": "^2.8.0",
|
"recharts": "^2.8.0",
|
||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
"tailwind-merge": "^1.14.0",
|
"tailwind-merge": "^1.14.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"usehooks-ts": "^2.9.1",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
3
apps/web/src/components/AutoSizer.tsx
Normal file
3
apps/web/src/components/AutoSizer.tsx
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import AutoSizer from "react-virtualized-auto-sizer";
|
||||||
|
|
||||||
|
export { AutoSizer }
|
||||||
@@ -17,6 +17,17 @@ import {
|
|||||||
} from "@/types";
|
} from "@/types";
|
||||||
import { getChartColor } from "@/utils/theme";
|
import { getChartColor } from "@/utils/theme";
|
||||||
import { ReportTable } from "./ReportTable";
|
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({
|
export function ReportLineChart({
|
||||||
interval,
|
interval,
|
||||||
@@ -24,13 +35,9 @@ export function ReportLineChart({
|
|||||||
endDate,
|
endDate,
|
||||||
events,
|
events,
|
||||||
breakdowns,
|
breakdowns,
|
||||||
}: {
|
showTable,
|
||||||
interval: IInterval;
|
}: ReportLineChartProps) {
|
||||||
startDate: Date;
|
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
|
||||||
endDate: Date;
|
|
||||||
events: IChartEvent[];
|
|
||||||
breakdowns: IChartBreakdown[];
|
|
||||||
}) {
|
|
||||||
const chart = api.chartMeta.chart.useQuery(
|
const chart = api.chartMeta.chart.useQuery(
|
||||||
{
|
{
|
||||||
interval,
|
interval,
|
||||||
@@ -47,12 +54,26 @@ export function ReportLineChart({
|
|||||||
|
|
||||||
const formatDate = useFormatDateInterval(interval);
|
const formatDate = useFormatDateInterval(interval);
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{chart.isSuccess && chart.data?.series?.[0]?.data && (
|
{chart.isSuccess && chart.data?.series?.[0]?.data && (
|
||||||
<>
|
<>
|
||||||
<LineChart width={800} height={400}>
|
<AutoSizer disableHeight>
|
||||||
<Legend />
|
{({ width }) => (
|
||||||
|
<LineChart width={width} height={width * 0.5}>
|
||||||
|
{/* <Legend /> */}
|
||||||
<YAxis dataKey={"count"}></YAxis>
|
<YAxis dataKey={"count"}></YAxis>
|
||||||
<Tooltip content={<ReportLineChartTooltip />} />
|
<Tooltip content={<ReportLineChartTooltip />} />
|
||||||
{/* <Tooltip /> */}
|
{/* <Tooltip /> */}
|
||||||
@@ -65,9 +86,16 @@ export function ReportLineChart({
|
|||||||
tickLine={false}
|
tickLine={false}
|
||||||
allowDuplicatedCategory={false}
|
allowDuplicatedCategory={false}
|
||||||
/>
|
/>
|
||||||
{chart.data?.series.slice(0, 5).map((serie, index) => {
|
{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 key = serie.name;
|
||||||
const strokeColor = getChartColor(index)
|
const strokeColor = getChartColor(realIndex);
|
||||||
return (
|
return (
|
||||||
<Line
|
<Line
|
||||||
type="monotone"
|
type="monotone"
|
||||||
@@ -82,7 +110,15 @@ export function ReportLineChart({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
<ReportTable data={chart.data} />
|
)}
|
||||||
|
</AutoSizer>
|
||||||
|
{showTable && (
|
||||||
|
<ReportTable
|
||||||
|
data={chart.data}
|
||||||
|
visibleSeries={visibleSeries}
|
||||||
|
setVisibleSeries={setVisibleSeries}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -4,48 +4,81 @@ import { useFormatDateInterval } from "@/hooks/useFormatDateInterval";
|
|||||||
import { useSelector } from "@/redux";
|
import { useSelector } from "@/redux";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { getChartColor } from "@/utils/theme";
|
import { getChartColor } from "@/utils/theme";
|
||||||
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
|
type ReportTableProps = {
|
||||||
|
data: RouterOutputs["chartMeta"]["chart"];
|
||||||
|
visibleSeries: string[];
|
||||||
|
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
};
|
||||||
|
|
||||||
export function ReportTable({
|
export function ReportTable({
|
||||||
data,
|
data,
|
||||||
}: {
|
visibleSeries,
|
||||||
data: RouterOutputs["chartMeta"]["chart"];
|
setVisibleSeries,
|
||||||
}) {
|
}: ReportTableProps) {
|
||||||
const interval = useSelector((state) => state.report.interval);
|
const interval = useSelector((state) => state.report.interval);
|
||||||
const formatDate = useFormatDateInterval(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 (
|
return (
|
||||||
<div className="flex min-w-0">
|
<div className="flex w-fit max-w-full rounded-md border border-border">
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
<div>
|
<div className="border-r border-border">
|
||||||
<div className="font-medium">Name</div>
|
<div className={cn(header, row, cell)}>Name</div>
|
||||||
{data.series.map((serie, index) => {
|
{data.series.map((serie, index) => {
|
||||||
const checked = index < 5;
|
const checked = visibleSeries.includes(serie.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={serie.name}
|
key={serie.name}
|
||||||
className="max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap flex items-center gap-2"
|
className={cn("flex max-w-[200px] items-center gap-2", row, cell)}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
style={checked ? {
|
onCheckedChange={(checked) =>
|
||||||
|
handleChange(serie.name, !!checked)
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
checked
|
||||||
|
? {
|
||||||
background: getChartColor(index),
|
background: getChartColor(index),
|
||||||
borderColor: getChartColor(index),
|
borderColor: getChartColor(index),
|
||||||
} : undefined}
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
/>
|
/>
|
||||||
|
<div className="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap">
|
||||||
{serie.name}
|
{serie.name}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ScrollView for all values */}
|
{/* ScrollView for all values */}
|
||||||
<div className="min-w-0 overflow-auto">
|
<div className="w-full overflow-auto">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex">
|
<div className={cn("w-max", row)}>
|
||||||
{data.series[0]?.data.map((serie, index) => (
|
<div className={cn(header, value, cell, total)}>Total</div>
|
||||||
|
{data.series[0]?.data.map((serie) => (
|
||||||
<div
|
<div
|
||||||
key={serie.date.toString()}
|
key={serie.date.toString()}
|
||||||
className="min-w-[80px] text-right font-medium"
|
className={cn(header, value, cell)}
|
||||||
>
|
>
|
||||||
{formatDate(serie.date)}
|
{formatDate(serie.date)}
|
||||||
</div>
|
</div>
|
||||||
@@ -53,12 +86,13 @@ export function ReportTable({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Values */}
|
{/* Values */}
|
||||||
{data.series.map((serie, index) => {
|
{data.series.map((serie) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex" key={serie.name}>
|
<div className={cn("w-max", row)} key={serie.name}>
|
||||||
|
<div className={cn(header, value, cell, total)}>{serie.totalCount}</div>
|
||||||
{serie.data.map((item) => {
|
{serie.data.map((item) => {
|
||||||
return (
|
return (
|
||||||
<div key={item.date} className="min-w-[80px] text-right">
|
<div key={item.date} className={cn(value, cell)}>
|
||||||
{item.count}
|
{item.count}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
type IChartEvent,
|
type IChartEvent,
|
||||||
type IInterval,
|
type IInterval,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
|
import { getDaysOldDate } from "@/utils/date";
|
||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit";
|
import { type PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
type InitialState = {
|
type InitialState = {
|
||||||
@@ -15,27 +16,20 @@ type InitialState = {
|
|||||||
|
|
||||||
// First approach: define the initial state using that type
|
// First approach: define the initial state using that type
|
||||||
const initialState: InitialState = {
|
const initialState: InitialState = {
|
||||||
startDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
|
startDate: getDaysOldDate(7),
|
||||||
endDate: new Date(),
|
endDate: new Date(),
|
||||||
interval: "day",
|
interval: "day",
|
||||||
breakdowns: [
|
breakdowns: [
|
||||||
{
|
{
|
||||||
id: "A",
|
id: "A",
|
||||||
name: "properties.params.title",
|
name: 'properties.id'
|
||||||
},
|
}
|
||||||
],
|
],
|
||||||
events: [
|
events: [
|
||||||
{
|
{
|
||||||
id: "A",
|
id: "A",
|
||||||
displayName: "screen_view (0)",
|
name: "sign_up",
|
||||||
name: "screen_view",
|
filters: []
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
id: "1",
|
|
||||||
name: "properties.route",
|
|
||||||
value: "RecipeDetails",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -115,6 +109,18 @@ export const reportSlice = createSlice({
|
|||||||
changeEndDate: (state, action: PayloadAction<Date>) => {
|
changeEndDate: (state, action: PayloadAction<Date>) => {
|
||||||
state.endDate = action.payload;
|
state.endDate = action.payload;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
changeDateRanges: (state, action: PayloadAction<number>) => {
|
||||||
|
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,
|
removeBreakdown,
|
||||||
changeBreakdown,
|
changeBreakdown,
|
||||||
changeInterval,
|
changeInterval,
|
||||||
|
changeDateRanges,
|
||||||
} = reportSlice.actions;
|
} = reportSlice.actions;
|
||||||
|
|
||||||
export default reportSlice.reducer;
|
export default reportSlice.reducer;
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ function Filter({ filter, event }: FilterProps) {
|
|||||||
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-emerald-600 text-xs font-medium text-white">
|
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-emerald-600 text-xs font-medium text-white">
|
||||||
<SlidersHorizontal size={10} />
|
<SlidersHorizontal size={10} />
|
||||||
</div>
|
</div>
|
||||||
<RenderDots className="text-sm">{filter.name}</RenderDots>
|
<RenderDots className="text-sm flex-1">{filter.name}</RenderDots>
|
||||||
<Button variant="ghost" size="sm" onClick={removeFilter}>
|
<Button variant="ghost" size="sm" onClick={removeFilter}>
|
||||||
<Trash size={16} />
|
<Trash size={16} />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -36,11 +36,11 @@ export function ReportEvents() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="mb-2 font-medium">Events</h3>
|
<h3 className="mb-2 font-medium">Events</h3>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{selectedEvents.map((event, index) => {
|
{selectedEvents.map((event) => {
|
||||||
return (
|
return (
|
||||||
<div key={event.name} className="border rounded-lg">
|
<div key={event.name} className="border rounded-lg">
|
||||||
<div className="flex gap-2 items-center p-2 px-4">
|
<div className="flex gap-2 items-center p-2 px-4">
|
||||||
<div className="flex-shrink-0 bg-purple-500 w-5 h-5 rounded text-xs flex items-center justify-center text-white font-medium">{index}</div>
|
<div className="flex-shrink-0 bg-purple-500 w-5 h-5 rounded text-xs flex items-center justify-center text-white font-medium">{event.id}</div>
|
||||||
<Combobox
|
<Combobox
|
||||||
value={event.name}
|
value={event.name}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -70,7 +70,6 @@ export function ReportEvents() {
|
|||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
dispatch(
|
dispatch(
|
||||||
addEvent({
|
addEvent({
|
||||||
displayName: `${value} (${selectedEvents.length})`,
|
|
||||||
name: value,
|
name: value,
|
||||||
filters: [],
|
filters: [],
|
||||||
}),
|
}),
|
||||||
|
|||||||
5
apps/web/src/components/ui/aspect-ratio.tsx
Normal file
5
apps/web/src/components/ui/aspect-ratio.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||||
|
|
||||||
|
const AspectRatio = AspectRatioPrimitive.Root
|
||||||
|
|
||||||
|
export { AspectRatio }
|
||||||
48
apps/web/src/components/ui/avatar.tsx
Normal file
48
apps/web/src/components/ui/avatar.tsx
Normal file
@@ -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<typeof AvatarPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||||
|
|
||||||
|
const AvatarImage = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Image
|
||||||
|
ref={ref}
|
||||||
|
className={cn("aspect-square h-full w-full", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||||
|
|
||||||
|
const AvatarFallback = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AvatarPrimitive.Fallback
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||||
|
|
||||||
|
export { Avatar, AvatarImage, AvatarFallback }
|
||||||
@@ -6,8 +6,15 @@ import { ReportLineChart } from "@/components/report/chart/ReportLineChart";
|
|||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { useDispatch, useSelector } from "@/redux";
|
import { useDispatch, useSelector } from "@/redux";
|
||||||
import { changeInterval } from "@/components/report/reportSlice";
|
import {
|
||||||
|
changeDateRanges,
|
||||||
|
changeInterval,
|
||||||
|
} from "@/components/report/reportSlice";
|
||||||
import { type IInterval } from "@/types";
|
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() {
|
export default function Home() {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -24,6 +31,50 @@ export default function Home() {
|
|||||||
<meta name="description" content="Generated by create-t3-app" />
|
<meta name="description" content="Generated by create-t3-app" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="/favicon.ico" />
|
||||||
</Head>
|
</Head>
|
||||||
|
<nav className="flex h-20 items-center border-b border-border px-8 justify-between bg-black text-white">
|
||||||
|
<div className="flex flex-col [&_*]:leading-tight">
|
||||||
|
<span className="text-2xl font-extrabold">MIXAN</span>
|
||||||
|
<span className="text-xs text-muted">v0.0.1</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4 uppercase text-sm">
|
||||||
|
<a href="#">Dashboards</a>
|
||||||
|
<a href="#">Reports</a>
|
||||||
|
<a href="#">Events</a>
|
||||||
|
<a href="#">Users</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button>
|
||||||
|
<Avatar className="text-black">
|
||||||
|
<AvatarFallback>CL</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[200px]">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
Profile
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem>
|
||||||
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
Organization
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem className="text-red-600">
|
||||||
|
<User className="mr-2 h-4 w-4" />
|
||||||
|
Logout
|
||||||
|
<DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
<main className="grid min-h-screen grid-cols-[400px_minmax(0,1fr)] divide-x">
|
<main className="grid min-h-screen grid-cols-[400px_minmax(0,1fr)] divide-x">
|
||||||
<div>
|
<div>
|
||||||
<ReportSidebar />
|
<ReportSidebar />
|
||||||
@@ -32,12 +83,55 @@ export default function Home() {
|
|||||||
<div className="flex flex-col gap-4 p-4">
|
<div className="flex flex-col gap-4 p-4">
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<RadioGroup>
|
<RadioGroup>
|
||||||
<RadioGroupItem>7 days</RadioGroupItem>
|
<RadioGroupItem
|
||||||
<RadioGroupItem>14 days</RadioGroupItem>
|
onClick={() => {
|
||||||
<RadioGroupItem>1 month</RadioGroupItem>
|
dispatch(changeDateRanges(1));
|
||||||
<RadioGroupItem>3 month</RadioGroupItem>
|
}}
|
||||||
<RadioGroupItem>6 month</RadioGroupItem>
|
>
|
||||||
<RadioGroupItem>1 year</RadioGroupItem>
|
Today
|
||||||
|
</RadioGroupItem>
|
||||||
|
<RadioGroupItem
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(changeDateRanges(1));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
7 days
|
||||||
|
</RadioGroupItem>
|
||||||
|
<RadioGroupItem
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(changeDateRanges(14));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
14 days
|
||||||
|
</RadioGroupItem>
|
||||||
|
<RadioGroupItem
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(changeDateRanges(30));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
1 month
|
||||||
|
</RadioGroupItem>
|
||||||
|
<RadioGroupItem
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(changeDateRanges(90));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
3 month
|
||||||
|
</RadioGroupItem>
|
||||||
|
<RadioGroupItem
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(changeDateRanges(180));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
6 month
|
||||||
|
</RadioGroupItem>
|
||||||
|
<RadioGroupItem
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(changeDateRanges(356));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
1 year
|
||||||
|
</RadioGroupItem>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<div className="w-full max-w-[200px]">
|
<div className="w-full max-w-[200px]">
|
||||||
<Combobox
|
<Combobox
|
||||||
@@ -70,6 +164,7 @@ export default function Home() {
|
|||||||
events={events}
|
events={events}
|
||||||
breakdowns={breakdowns}
|
breakdowns={breakdowns}
|
||||||
interval={interval}
|
interval={interval}
|
||||||
|
showTable
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ function propertyNameToSql(name: string) {
|
|||||||
return name;
|
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 = {
|
export const config = {
|
||||||
api: {
|
api: {
|
||||||
responseLimit: false,
|
responseLimit: false,
|
||||||
@@ -114,9 +122,14 @@ async function getChartData({
|
|||||||
console.log(sql);
|
console.log(sql);
|
||||||
|
|
||||||
const result = await db.$queryRawUnsafe<ResultItem[]>(sql);
|
const result = await db.$queryRawUnsafe<ResultItem[]>(sql);
|
||||||
|
|
||||||
|
// group by sql label
|
||||||
const series = result.reduce(
|
const series = result.reduce(
|
||||||
(acc, item) => {
|
(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 (label) {
|
||||||
if (acc[label]) {
|
if (acc[label]) {
|
||||||
acc[label]?.push(item);
|
acc[label]?.push(item);
|
||||||
@@ -133,17 +146,20 @@ async function getChartData({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return Object.keys(series).map((key) => {
|
return Object.keys(series).map((key) => {
|
||||||
|
const legend = breakdowns.length ? key : getEventLegend(event);
|
||||||
|
const data = series[key] ?? []
|
||||||
return {
|
return {
|
||||||
name: breakdowns.length ? key ?? "break a leg" : event.displayName,
|
name: legend,
|
||||||
|
totalCount: getTotalCount(data),
|
||||||
data: fillEmptySpotsInTimeline(
|
data: fillEmptySpotsInTimeline(
|
||||||
series[key] ?? [],
|
data,
|
||||||
interval,
|
interval,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
).map((item) => {
|
).map((item) => {
|
||||||
return {
|
return {
|
||||||
...item,
|
label: legend,
|
||||||
label: breakdowns.length ? key ?? "break a leg" : event.displayName,
|
count: item.count,
|
||||||
date: new Date(item.date).toISOString(),
|
date: new Date(item.date).toISOString(),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function getDaysOldDate(days: number) {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() - days);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import { z } from "zod";
|
|||||||
export const zChartEvent = z.object({
|
export const zChartEvent = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
displayName: z.string(),
|
|
||||||
filters: z.array(
|
filters: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
|
|||||||
Reference in New Issue
Block a user