save, create and view reports in dashboard
This commit is contained in:
@@ -20,7 +20,8 @@ const config = {
|
|||||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||||
"@typescript-eslint/no-unsafe-argument": "off",
|
"@typescript-eslint/no-unsafe-argument": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-return": "off",
|
||||||
|
"@typescript-eslint/no-unsafe-call": "off",
|
||||||
"@typescript-eslint/consistent-type-imports": [
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
"warn",
|
"warn",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "Interval" AS ENUM ('hour', 'day', 'month');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "ChartType" AS ENUM ('linear', 'bar', 'pie', 'metric', 'area');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "dashboards" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"project_id" UUID NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "dashboards_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "reports" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"interval" "Interval" NOT NULL,
|
||||||
|
"range" INTEGER NOT NULL,
|
||||||
|
"chart_type" "ChartType" NOT NULL,
|
||||||
|
"breakdowns" JSONB NOT NULL,
|
||||||
|
"events" JSONB NOT NULL,
|
||||||
|
"project_id" UUID NOT NULL,
|
||||||
|
"dashboard_id" UUID NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "reports_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "dashboards" ADD CONSTRAINT "dashboards_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "reports" ADD CONSTRAINT "reports_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "reports" ADD CONSTRAINT "reports_dashboard_id_fkey" FOREIGN KEY ("dashboard_id") REFERENCES "dashboards"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `name` to the `reports` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "reports" ADD COLUMN "name" TEXT NOT NULL;
|
||||||
@@ -31,8 +31,10 @@ model Project {
|
|||||||
profiles Profile[]
|
profiles Profile[]
|
||||||
clients Client[]
|
clients Client[]
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
reports Report[]
|
||||||
|
dashboards Dashboard[]
|
||||||
|
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
}
|
}
|
||||||
@@ -97,3 +99,50 @@ model Client {
|
|||||||
|
|
||||||
@@map("clients")
|
@@map("clients")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Interval {
|
||||||
|
hour
|
||||||
|
day
|
||||||
|
month
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChartType {
|
||||||
|
linear
|
||||||
|
bar
|
||||||
|
pie
|
||||||
|
metric
|
||||||
|
area
|
||||||
|
}
|
||||||
|
|
||||||
|
model Dashboard {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
|
name String
|
||||||
|
project_id String @db.Uuid
|
||||||
|
project Project @relation(fields: [project_id], references: [id])
|
||||||
|
reports Report[]
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
@@map("dashboards")
|
||||||
|
}
|
||||||
|
|
||||||
|
model Report {
|
||||||
|
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
|
||||||
|
name String
|
||||||
|
interval Interval
|
||||||
|
range Int
|
||||||
|
chart_type ChartType
|
||||||
|
breakdowns Json
|
||||||
|
events Json
|
||||||
|
project_id String @db.Uuid
|
||||||
|
project Project @relation(fields: [project_id], references: [id])
|
||||||
|
|
||||||
|
dashboard_id String @db.Uuid
|
||||||
|
dashboard Dashboard @relation(fields: [dashboard_id], references: [id])
|
||||||
|
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
|
|
||||||
|
@@map("reports")
|
||||||
|
}
|
||||||
|
|||||||
8
apps/web/src/components/Container.tsx
Normal file
8
apps/web/src/components/Container.tsx
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { type HtmlProps } from "@/types";
|
||||||
|
import { cn } from "@/utils/cn";
|
||||||
|
|
||||||
|
export function Container({className,...props}: HtmlProps<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div className={cn("mx-auto w-full max-w-4xl", className)} {...props} />
|
||||||
|
);
|
||||||
|
}
|
||||||
10
apps/web/src/components/Navbar.tsx
Normal file
10
apps/web/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export function Navbar() {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 text-sm uppercase">
|
||||||
|
<Link href="/">Dashboards</Link>
|
||||||
|
<Link href="/reports">Reports</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
import {
|
|
||||||
Cloud,
|
|
||||||
CreditCard,
|
|
||||||
Github,
|
|
||||||
Keyboard,
|
|
||||||
LifeBuoy,
|
|
||||||
LogOut,
|
|
||||||
Mail,
|
|
||||||
MessageSquare,
|
|
||||||
Plus,
|
|
||||||
PlusCircle,
|
|
||||||
Settings,
|
|
||||||
User,
|
|
||||||
UserPlus,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuGroup,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuLabel,
|
|
||||||
DropdownMenuPortal,
|
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuShortcut,
|
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import { useState } from "react"
|
|
||||||
|
|
||||||
export function ReportFilterPicker() {
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
return (
|
|
||||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline">Open</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="w-56">
|
|
||||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<User className="mr-2 h-4 w-4" />
|
|
||||||
<span>Profile</span>
|
|
||||||
<DropdownMenuShortcut>⇧⌘P</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<CreditCard className="mr-2 h-4 w-4" />
|
|
||||||
<span>Billing</span>
|
|
||||||
<DropdownMenuShortcut>⌘B</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Settings className="mr-2 h-4 w-4" />
|
|
||||||
<span>Settings</span>
|
|
||||||
<DropdownMenuShortcut>⌘S</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Keyboard className="mr-2 h-4 w-4" />
|
|
||||||
<span>Keyboard shortcuts</span>
|
|
||||||
<DropdownMenuShortcut>⌘K</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuGroup>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Users className="mr-2 h-4 w-4" />
|
|
||||||
<span>Team</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger>
|
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
|
||||||
<span>Invite users</span>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuPortal>
|
|
||||||
<DropdownMenuSubContent>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Mail className="mr-2 h-4 w-4" />
|
|
||||||
<span>Email</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
|
||||||
<span>Message</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<PlusCircle className="mr-2 h-4 w-4" />
|
|
||||||
<span>More...</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuPortal>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
|
||||||
<span>New Team</span>
|
|
||||||
<DropdownMenuShortcut>⌘+T</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuGroup>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Github className="mr-2 h-4 w-4" />
|
|
||||||
<span>GitHub</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<LifeBuoy className="mr-2 h-4 w-4" />
|
|
||||||
<span>Support</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem disabled>
|
|
||||||
<Cloud className="mr-2 h-4 w-4" />
|
|
||||||
<span>API</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<LogOut className="mr-2 h-4 w-4" />
|
|
||||||
<span>Log out</span>
|
|
||||||
<DropdownMenuShortcut>⇧⌘Q</DropdownMenuShortcut>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
34
apps/web/src/components/UserDropdown.tsx
Normal file
34
apps/web/src/components/UserDropdown.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { User } from "lucide-react";
|
||||||
|
|
||||||
|
export function UserDropdown() {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<button>
|
||||||
|
<Avatar className="text-black">
|
||||||
|
<AvatarFallback>CL</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-[200px]">
|
||||||
|
<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
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
35
apps/web/src/components/layouts/Main.tsx
Normal file
35
apps/web/src/components/layouts/Main.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import Head from "next/head";
|
||||||
|
import { UserDropdown } from "../UserDropdown";
|
||||||
|
import { Navbar } from "../Navbar";
|
||||||
|
|
||||||
|
type MainLayoutProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MainLayout({ children, className }: MainLayoutProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Head>
|
||||||
|
<title>Create T3 App</title>
|
||||||
|
<meta name="description" content="Generated by create-t3-app" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
</Head>
|
||||||
|
<nav className="flex h-20 items-center justify-between border-b border-border bg-black px-8 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>
|
||||||
|
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<UserDropdown />
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<main className={className}>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/web/src/components/report/ReportDateRange.tsx
Normal file
89
apps/web/src/components/report/ReportDateRange.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useDispatch, useSelector } from "@/redux";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
|
||||||
|
import { changeDateRanges, changeInterval } from "./reportSlice";
|
||||||
|
import { Combobox } from "../ui/combobox";
|
||||||
|
import { type IInterval } from "@/types";
|
||||||
|
|
||||||
|
export function ReportDateRange() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const interval = useSelector((state) => state.report.interval);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RadioGroup>
|
||||||
|
<RadioGroupItem
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(changeDateRanges(1));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Today
|
||||||
|
</RadioGroupItem>
|
||||||
|
<RadioGroupItem
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(changeDateRanges(7));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
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>
|
||||||
|
<div className="w-full max-w-[200px]">
|
||||||
|
<Combobox
|
||||||
|
placeholder="Interval"
|
||||||
|
onChange={(value) => {
|
||||||
|
dispatch(changeInterval(value as IInterval));
|
||||||
|
}}
|
||||||
|
value={interval}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: "Hour",
|
||||||
|
value: "hour",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Day",
|
||||||
|
value: "day",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Month",
|
||||||
|
value: "month",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
></Combobox>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { api } from "@/utils/api";
|
import { api } from "@/utils/api";
|
||||||
import {
|
import {
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
Legend,
|
|
||||||
Line,
|
Line,
|
||||||
LineChart,
|
LineChart,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
@@ -10,22 +9,13 @@ import {
|
|||||||
} from "recharts";
|
} from "recharts";
|
||||||
import { ReportLineChartTooltip } from "./ReportLineChartTooltop";
|
import { ReportLineChartTooltip } from "./ReportLineChartTooltop";
|
||||||
import { useFormatDateInterval } from "@/hooks/useFormatDateInterval";
|
import { useFormatDateInterval } from "@/hooks/useFormatDateInterval";
|
||||||
import {
|
import { type IChartInput } from "@/types";
|
||||||
type IChartBreakdown,
|
|
||||||
type IChartEvent,
|
|
||||||
type IInterval,
|
|
||||||
} 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 { useEffect, useRef, useState } from "react";
|
||||||
import { AutoSizer } from "@/components/AutoSizer";
|
import { AutoSizer } from "@/components/AutoSizer";
|
||||||
|
|
||||||
type ReportLineChartProps = {
|
type ReportLineChartProps = IChartInput & {
|
||||||
interval: IInterval;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
events: IChartEvent[];
|
|
||||||
breakdowns: IChartBreakdown[];
|
|
||||||
showTable?: boolean;
|
showTable?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -36,16 +26,20 @@ export function ReportLineChart({
|
|||||||
events,
|
events,
|
||||||
breakdowns,
|
breakdowns,
|
||||||
showTable,
|
showTable,
|
||||||
|
chartType,
|
||||||
|
name,
|
||||||
}: ReportLineChartProps) {
|
}: ReportLineChartProps) {
|
||||||
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
|
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
|
||||||
|
|
||||||
const chart = api.chartMeta.chart.useQuery(
|
const chart = api.chartMeta.chart.useQuery(
|
||||||
{
|
{
|
||||||
interval,
|
interval,
|
||||||
chartType: "linear",
|
chartType,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
events,
|
events,
|
||||||
breakdowns,
|
breakdowns,
|
||||||
|
name,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
enabled: events.length > 0,
|
enabled: events.length > 0,
|
||||||
@@ -58,25 +52,23 @@ export function ReportLineChart({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ref.current && chart.data) {
|
if (!ref.current && chart.data) {
|
||||||
const max = 20;
|
const max = 20;
|
||||||
|
|
||||||
setVisibleSeries(
|
setVisibleSeries(
|
||||||
chart.data?.series?.slice(0, max).map((serie) => serie.name) ?? [],
|
chart.data?.series?.slice(0, max).map((serie) => serie.name) ?? [],
|
||||||
);
|
);
|
||||||
// ref.current = true;
|
// ref.current = true;
|
||||||
}
|
}
|
||||||
}, [chart.data]);
|
}, [chart.data]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{chart.isSuccess && chart.data?.series?.[0]?.data && (
|
{chart.isSuccess && chart.data?.series?.[0]?.data && (
|
||||||
<>
|
<>
|
||||||
<AutoSizer disableHeight>
|
<AutoSizer disableHeight>
|
||||||
{({ width }) => (
|
{({ width }) => (
|
||||||
<LineChart width={width} height={width * 0.5}>
|
<LineChart width={width} height={Math.min(width * 0.5, 400)}>
|
||||||
{/* <Legend /> */}
|
|
||||||
<YAxis dataKey={"count"}></YAxis>
|
<YAxis dataKey={"count"}></YAxis>
|
||||||
<Tooltip content={<ReportLineChartTooltip />} />
|
<Tooltip content={<ReportLineChartTooltip />} />
|
||||||
{/* <Tooltip /> */}
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
<XAxis
|
<XAxis
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
|
|||||||
9
apps/web/src/components/report/hooks/useReportId.ts
Normal file
9
apps/web/src/components/report/hooks/useReportId.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { useQueryParams } from "@/hooks/useQueryParams";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const useReportId = () =>
|
||||||
|
useQueryParams(
|
||||||
|
z.object({
|
||||||
|
reportId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
type IChartInput,
|
||||||
type IChartBreakdown,
|
type IChartBreakdown,
|
||||||
type IChartEvent,
|
type IChartEvent,
|
||||||
type IInterval,
|
type IInterval,
|
||||||
@@ -6,32 +7,17 @@ import {
|
|||||||
import { getDaysOldDate } from "@/utils/date";
|
import { getDaysOldDate } from "@/utils/date";
|
||||||
import { type PayloadAction, createSlice } from "@reduxjs/toolkit";
|
import { type PayloadAction, createSlice } from "@reduxjs/toolkit";
|
||||||
|
|
||||||
type InitialState = {
|
type InitialState = IChartInput;
|
||||||
events: IChartEvent[];
|
|
||||||
breakdowns: IChartBreakdown[];
|
|
||||||
interval: IInterval;
|
|
||||||
startDate: Date;
|
|
||||||
endDate: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
// First approach: define the initial state using that type
|
// First approach: define the initial state using that type
|
||||||
const initialState: InitialState = {
|
const initialState: InitialState = {
|
||||||
|
name: "",
|
||||||
|
chartType: "linear",
|
||||||
startDate: getDaysOldDate(7),
|
startDate: getDaysOldDate(7),
|
||||||
endDate: new Date(),
|
endDate: new Date(),
|
||||||
interval: "day",
|
interval: "day",
|
||||||
breakdowns: [
|
breakdowns: [],
|
||||||
{
|
events: [],
|
||||||
id: "A",
|
|
||||||
name: 'properties.id'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
id: "A",
|
|
||||||
name: "sign_up",
|
|
||||||
filters: []
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const IDS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"] as const;
|
const IDS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"] as const;
|
||||||
@@ -40,6 +26,12 @@ export const reportSlice = createSlice({
|
|||||||
name: "counter",
|
name: "counter",
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
|
reset() {
|
||||||
|
return initialState
|
||||||
|
},
|
||||||
|
setReport(state, action: PayloadAction<IChartInput>) {
|
||||||
|
return action.payload
|
||||||
|
},
|
||||||
// Events
|
// Events
|
||||||
addEvent: (state, action: PayloadAction<Omit<IChartEvent, "id">>) => {
|
addEvent: (state, action: PayloadAction<Omit<IChartEvent, "id">>) => {
|
||||||
state.events.push({
|
state.events.push({
|
||||||
@@ -111,21 +103,23 @@ export const reportSlice = createSlice({
|
|||||||
},
|
},
|
||||||
|
|
||||||
changeDateRanges: (state, action: PayloadAction<number>) => {
|
changeDateRanges: (state, action: PayloadAction<number>) => {
|
||||||
if(action.payload === 1) {
|
if (action.payload === 1) {
|
||||||
state.interval = "hour";
|
state.interval = "hour";
|
||||||
} else if(action.payload <= 30) {
|
} else if (action.payload <= 30) {
|
||||||
state.interval = "day";
|
state.interval = "day";
|
||||||
} else {
|
} else {
|
||||||
state.interval = "month";
|
state.interval = "month";
|
||||||
}
|
}
|
||||||
state.startDate = getDaysOldDate(action.payload);
|
state.startDate = getDaysOldDate(action.payload);
|
||||||
state.endDate = new Date();
|
state.endDate = new Date();
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Action creators are generated for each case reducer function
|
// Action creators are generated for each case reducer function
|
||||||
export const {
|
export const {
|
||||||
|
reset,
|
||||||
|
setReport,
|
||||||
addEvent,
|
addEvent,
|
||||||
removeEvent,
|
removeEvent,
|
||||||
changeEvent,
|
changeEvent,
|
||||||
|
|||||||
36
apps/web/src/components/report/sidebar/ReportSave.tsx
Normal file
36
apps/web/src/components/report/sidebar/ReportSave.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useReportId } from "../hooks/useReportId";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { useSelector } from "@/redux";
|
||||||
|
|
||||||
|
export function ReportSave() {
|
||||||
|
const { reportId } = useReportId();
|
||||||
|
const save = api.report.save.useMutation();
|
||||||
|
const update = api.report.update.useMutation();
|
||||||
|
const report = useSelector((state) => state.report);
|
||||||
|
|
||||||
|
if (reportId) {
|
||||||
|
return <Button onClick={() => {
|
||||||
|
update.mutate({
|
||||||
|
reportId,
|
||||||
|
report,
|
||||||
|
dashboardId: "9227feb4-ad59-40f3-b887-3501685733dd",
|
||||||
|
projectId: "f7eabf0c-e0b0-4ac0-940f-1589715b0c3d",
|
||||||
|
});
|
||||||
|
}}>Update</Button>;
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
save.mutate({
|
||||||
|
report,
|
||||||
|
dashboardId: "9227feb4-ad59-40f3-b887-3501685733dd",
|
||||||
|
projectId: "f7eabf0c-e0b0-4ac0-940f-1589715b0c3d",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { ReportEvents } from "./ReportEvents";
|
import { ReportEvents } from "./ReportEvents";
|
||||||
import { ReportBreakdowns } from "./ReportBreakdowns";
|
import { ReportBreakdowns } from "./ReportBreakdowns";
|
||||||
|
import { ReportSave } from "./ReportSave";
|
||||||
|
|
||||||
export function ReportSidebar() {
|
export function ReportSidebar() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 p-4">
|
<div className="flex flex-col gap-4 p-4">
|
||||||
<ReportEvents />
|
<ReportEvents />
|
||||||
<ReportBreakdowns />
|
<ReportBreakdowns />
|
||||||
|
<ReportSave />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
35
apps/web/src/hooks/useQueryParams.ts
Normal file
35
apps/web/src/hooks/useQueryParams.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { type z } from "zod";
|
||||||
|
|
||||||
|
export function useQueryParams<Z extends z.ZodTypeAny = z.ZodNever>(zod: Z) {
|
||||||
|
const router = useRouter();
|
||||||
|
const value = zod.safeParse(router.query);
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
function setQueryParams(newValue: Partial<z.infer<Z>>) {
|
||||||
|
return router
|
||||||
|
.replace({
|
||||||
|
pathname: router.pathname,
|
||||||
|
query: {
|
||||||
|
...router.query,
|
||||||
|
...newValue,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// ignore
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.success) {
|
||||||
|
return { ...value.data, setQueryParams } as z.infer<Z> & {
|
||||||
|
setQueryParams: typeof setQueryParams;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...router.query, setQueryParams } as z.infer<Z> & {
|
||||||
|
setQueryParams: typeof setQueryParams;
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [router.asPath, value.success]);
|
||||||
|
}
|
||||||
21
apps/web/src/hooks/useRouterBeforeLeave.ts
Normal file
21
apps/web/src/hooks/useRouterBeforeLeave.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useRouter } from "next/router";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
export function useRouterBeforeLeave(callback: () => void) {
|
||||||
|
const router = useRouter();
|
||||||
|
const prevUrl = useRef(router.asPath);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleRouteChange = (url: string) => {
|
||||||
|
if (prevUrl.current !== url) {
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
prevUrl.current = url;
|
||||||
|
};
|
||||||
|
|
||||||
|
router.events.on("routeChangeStart", handleRouteChange);
|
||||||
|
return () => {
|
||||||
|
router.events.off("routeChangeStart", handleRouteChange);
|
||||||
|
};
|
||||||
|
}, [router, callback]);
|
||||||
|
}
|
||||||
@@ -1,174 +1,33 @@
|
|||||||
import Head from "next/head";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { ReportSidebar } from "@/components/report/sidebar/ReportSidebar";
|
|
||||||
import { ReportLineChart } from "@/components/report/chart/ReportLineChart";
|
import { ReportLineChart } from "@/components/report/chart/ReportLineChart";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { MainLayout } from "@/components/layouts/Main";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Container } from "@/components/Container";
|
||||||
import { useDispatch, useSelector } from "@/redux";
|
import { api } from "@/utils/api";
|
||||||
import {
|
import Link from "next/link";
|
||||||
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() {
|
export default function Home() {
|
||||||
const dispatch = useDispatch();
|
const reportsQuery = api.report.getDashboard.useQuery({
|
||||||
const interval = useSelector((state) => state.report.interval);
|
projectId: 'f7eabf0c-e0b0-4ac0-940f-1589715b0c3d',
|
||||||
const events = useSelector((state) => state.report.events);
|
dashboardId: '9227feb4-ad59-40f3-b887-3501685733dd',
|
||||||
const breakdowns = useSelector((state) => state.report.breakdowns);
|
}, {
|
||||||
const startDate = useSelector((state) => state.report.startDate);
|
staleTime: 1000 * 60 * 5,
|
||||||
const endDate = useSelector((state) => state.report.endDate);
|
})
|
||||||
|
const reports = reportsQuery.data ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<MainLayout className="bg-slate-50 py-8">
|
||||||
<Head>
|
<Container className="flex flex-col gap-8">
|
||||||
<title>Create T3 App</title>
|
{reports.map((report) => (
|
||||||
<meta name="description" content="Generated by create-t3-app" />
|
<div
|
||||||
<link rel="icon" href="/favicon.ico" />
|
className="rounded-xl border border-border bg-white shadow"
|
||||||
</Head>
|
key={report.id}
|
||||||
<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">
|
<Link href={`/reports/${report.id}`} className="block border-b border-border p-4 font-bold hover:underline">{report.name}</Link>
|
||||||
<span className="text-2xl font-extrabold">MIXAN</span>
|
<div className="p-4">
|
||||||
<span className="text-xs text-muted">v0.0.1</span>
|
<ReportLineChart {...report} showTable={false} />
|
||||||
</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">
|
|
||||||
<div>
|
|
||||||
<ReportSidebar />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 p-4">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<RadioGroup>
|
|
||||||
<RadioGroupItem
|
|
||||||
onClick={() => {
|
|
||||||
dispatch(changeDateRanges(1));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
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>
|
|
||||||
<div className="w-full max-w-[200px]">
|
|
||||||
<Combobox
|
|
||||||
placeholder="Interval"
|
|
||||||
onChange={(value) => {
|
|
||||||
dispatch(changeInterval(value as IInterval));
|
|
||||||
}}
|
|
||||||
value={interval}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
label: "Hour",
|
|
||||||
value: "hour",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Day",
|
|
||||||
value: "day",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Month",
|
|
||||||
value: "month",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
></Combobox>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{startDate && endDate && (
|
))}
|
||||||
<ReportLineChart
|
</Container>
|
||||||
startDate={startDate}
|
</MainLayout>
|
||||||
endDate={endDate}
|
|
||||||
events={events}
|
|
||||||
breakdowns={breakdowns}
|
|
||||||
interval={interval}
|
|
||||||
showTable
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1
apps/web/src/pages/reports/[reportId].tsx
Normal file
1
apps/web/src/pages/reports/[reportId].tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { default } from "./index";
|
||||||
46
apps/web/src/pages/reports/index.tsx
Normal file
46
apps/web/src/pages/reports/index.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { ReportSidebar } from "@/components/report/sidebar/ReportSidebar";
|
||||||
|
import { ReportLineChart } from "@/components/report/chart/ReportLineChart";
|
||||||
|
import { useDispatch, useSelector } from "@/redux";
|
||||||
|
import { MainLayout } from "@/components/layouts/Main";
|
||||||
|
import { ReportDateRange } from "@/components/report/ReportDateRange";
|
||||||
|
import { useCallback, useEffect } from "react";
|
||||||
|
import { reset, setReport } from "@/components/report/reportSlice";
|
||||||
|
import { useReportId } from "@/components/report/hooks/useReportId";
|
||||||
|
import { api } from "@/utils/api";
|
||||||
|
import { useRouterBeforeLeave } from "@/hooks/useRouterBeforeLeave";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { reportId } = useReportId();
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
const report = useSelector((state) => state.report);
|
||||||
|
const reportQuery = api.report.get.useQuery({ id: String(reportId) }, {
|
||||||
|
enabled: Boolean(reportId),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reset report state before leaving
|
||||||
|
useRouterBeforeLeave(useCallback(() => {
|
||||||
|
dispatch(reset())
|
||||||
|
}, [dispatch]))
|
||||||
|
|
||||||
|
// Set report if reportId exists
|
||||||
|
useEffect(() => {
|
||||||
|
if(reportId && reportQuery.data) {
|
||||||
|
dispatch(setReport(reportQuery.data))
|
||||||
|
}
|
||||||
|
}, [reportId, reportQuery.data, dispatch])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MainLayout className="grid min-h-screen grid-cols-[400px_minmax(0,1fr)] divide-x">
|
||||||
|
<div>
|
||||||
|
<ReportSidebar />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-4 p-4">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<ReportDateRange />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ReportLineChart {...report} showTable />
|
||||||
|
</div>
|
||||||
|
</MainLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { exampleRouter } from "@/server/api/routers/example";
|
import { exampleRouter } from "@/server/api/routers/example";
|
||||||
import { createTRPCRouter } from "@/server/api/trpc";
|
import { createTRPCRouter } from "@/server/api/trpc";
|
||||||
import { chartMetaRouter } from "./routers/chartMeta";
|
import { chartMetaRouter } from "./routers/chartMeta";
|
||||||
|
import { reportRouter } from "./routers/report";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -9,7 +10,8 @@ import { chartMetaRouter } from "./routers/chartMeta";
|
|||||||
*/
|
*/
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
example: exampleRouter,
|
example: exampleRouter,
|
||||||
chartMeta: chartMetaRouter
|
chartMeta: chartMetaRouter,
|
||||||
|
report: reportRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -5,12 +5,7 @@ import { db } from "@/server/db";
|
|||||||
import { map, path, pipe, sort, uniq } from "ramda";
|
import { map, path, pipe, sort, uniq } from "ramda";
|
||||||
import { toDots } from "@/utils/object";
|
import { toDots } from "@/utils/object";
|
||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import {
|
import { zChartInput } from "@/utils/validation";
|
||||||
zChartBreakdowns,
|
|
||||||
zChartEvents,
|
|
||||||
zChartType,
|
|
||||||
zTimeInterval,
|
|
||||||
} from "@/utils/validation";
|
|
||||||
import { type IChartBreakdown, type IChartEvent } from "@/types";
|
import { type IChartBreakdown, type IChartEvent } from "@/types";
|
||||||
|
|
||||||
type ResultItem = {
|
type ResultItem = {
|
||||||
@@ -21,17 +16,25 @@ type ResultItem = {
|
|||||||
|
|
||||||
function propertyNameToSql(name: string) {
|
function propertyNameToSql(name: string) {
|
||||||
if (name.includes(".")) {
|
if (name.includes(".")) {
|
||||||
return name
|
const str = name
|
||||||
.split(".")
|
.split(".")
|
||||||
.map((item, index) => (index === 0 ? item : `'${item}'`))
|
.map((item, index) => (index === 0 ? item : `'${item}'`))
|
||||||
.join("->");
|
.join("->");
|
||||||
|
const findLastOf = "->"
|
||||||
|
const lastArrow = str.lastIndexOf(findLastOf);
|
||||||
|
if(lastArrow === -1) {
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
const first = str.slice(0, lastArrow);
|
||||||
|
const last = str.slice(lastArrow + findLastOf.length);
|
||||||
|
return `${first}->>${last}`
|
||||||
}
|
}
|
||||||
|
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEventLegend(event: IChartEvent) {
|
function getEventLegend(event: IChartEvent) {
|
||||||
return `${event.name} (${event.id})`
|
return `${event.name} (${event.id})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTotalCount(arr: ResultItem[]) {
|
function getTotalCount(arr: ResultItem[]) {
|
||||||
@@ -84,7 +87,7 @@ async function getChartData({
|
|||||||
filters.forEach((filter) => {
|
filters.forEach((filter) => {
|
||||||
const { name, value } = filter;
|
const { name, value } = filter;
|
||||||
if (name.includes(".")) {
|
if (name.includes(".")) {
|
||||||
where.push(`${propertyNameToSql(name)} = '"${value}"'`);
|
where.push(`${propertyNameToSql(name)} = '${value}'`);
|
||||||
} else {
|
} else {
|
||||||
where.push(`${name} = '${value}'`);
|
where.push(`${name} = '${value}'`);
|
||||||
}
|
}
|
||||||
@@ -119,9 +122,8 @@ async function getChartData({
|
|||||||
GROUP BY ${groupBy.join(", ")}
|
GROUP BY ${groupBy.join(", ")}
|
||||||
ORDER BY ${orderBy.join(", ")}
|
ORDER BY ${orderBy.join(", ")}
|
||||||
`;
|
`;
|
||||||
console.log(sql);
|
|
||||||
|
|
||||||
const result = await db.$queryRawUnsafe<ResultItem[]>(sql);
|
const result = await db.$queryRawUnsafe<ResultItem[]>(sql);
|
||||||
|
|
||||||
// group by sql label
|
// group by sql label
|
||||||
const series = result.reduce(
|
const series = result.reduce(
|
||||||
@@ -129,7 +131,7 @@ async function getChartData({
|
|||||||
// item.label can be null when using breakdowns on a property
|
// item.label can be null when using breakdowns on a property
|
||||||
// that doesn't exist on all events
|
// that doesn't exist on all events
|
||||||
// fallback on event legend
|
// fallback on event legend
|
||||||
const label = item.label?.trim() ?? getEventLegend(event)
|
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);
|
||||||
@@ -147,22 +149,19 @@ async function getChartData({
|
|||||||
|
|
||||||
return Object.keys(series).map((key) => {
|
return Object.keys(series).map((key) => {
|
||||||
const legend = breakdowns.length ? key : getEventLegend(event);
|
const legend = breakdowns.length ? key : getEventLegend(event);
|
||||||
const data = series[key] ?? []
|
const data = series[key] ?? [];
|
||||||
return {
|
return {
|
||||||
name: legend,
|
name: legend,
|
||||||
totalCount: getTotalCount(data),
|
totalCount: getTotalCount(data),
|
||||||
data: fillEmptySpotsInTimeline(
|
data: fillEmptySpotsInTimeline(data, interval, startDate, endDate).map(
|
||||||
data,
|
(item) => {
|
||||||
interval,
|
return {
|
||||||
startDate,
|
label: legend,
|
||||||
endDate,
|
count: item.count,
|
||||||
).map((item) => {
|
date: new Date(item.date).toISOString(),
|
||||||
return {
|
};
|
||||||
label: legend,
|
},
|
||||||
count: item.count,
|
),
|
||||||
date: new Date(item.date).toISOString(),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -237,16 +236,7 @@ export const chartMetaRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
chart: protectedProcedure
|
chart: protectedProcedure
|
||||||
.input(
|
.input(zChartInput)
|
||||||
z.object({
|
|
||||||
startDate: z.date().nullish(),
|
|
||||||
endDate: z.date().nullish(),
|
|
||||||
chartType: zChartType,
|
|
||||||
interval: zTimeInterval,
|
|
||||||
events: zChartEvents,
|
|
||||||
breakdowns: zChartBreakdowns,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.query(
|
.query(
|
||||||
async ({
|
async ({
|
||||||
input: { chartType, events, breakdowns, interval, ...input },
|
input: { chartType, events, breakdowns, interval, ...input },
|
||||||
@@ -285,12 +275,17 @@ function fillEmptySpotsInTimeline(
|
|||||||
endDate: Date,
|
endDate: Date,
|
||||||
) {
|
) {
|
||||||
const result = [];
|
const result = [];
|
||||||
const currentDate = new Date(startDate);
|
const clonedStartDate = new Date(startDate);
|
||||||
currentDate.setHours(2, 0, 0, 0);
|
const clonedEndDate = new Date(endDate);
|
||||||
const modifiedEndDate = new Date(endDate);
|
if(interval === 'hour') {
|
||||||
modifiedEndDate.setHours(2, 0, 0, 0);
|
clonedStartDate.setMinutes(0, 0, 0);
|
||||||
|
clonedEndDate.setMinutes(0, 0, 0)
|
||||||
while (currentDate.getTime() <= modifiedEndDate.getTime()) {
|
} else {
|
||||||
|
clonedStartDate.setHours(2, 0, 0, 0);
|
||||||
|
clonedEndDate.setHours(2, 0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (clonedStartDate.getTime() <= clonedEndDate.getTime()) {
|
||||||
const getYear = (date: Date) => date.getFullYear();
|
const getYear = (date: Date) => date.getFullYear();
|
||||||
const getMonth = (date: Date) => date.getMonth();
|
const getMonth = (date: Date) => date.getMonth();
|
||||||
const getDay = (date: Date) => date.getDate();
|
const getDay = (date: Date) => date.getDate();
|
||||||
@@ -302,32 +297,32 @@ function fillEmptySpotsInTimeline(
|
|||||||
|
|
||||||
if (interval === "month") {
|
if (interval === "month") {
|
||||||
return (
|
return (
|
||||||
getYear(date) === getYear(currentDate) &&
|
getYear(date) === getYear(clonedStartDate) &&
|
||||||
getMonth(date) === getMonth(currentDate)
|
getMonth(date) === getMonth(clonedStartDate)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (interval === "day") {
|
if (interval === "day") {
|
||||||
return (
|
return (
|
||||||
getYear(date) === getYear(currentDate) &&
|
getYear(date) === getYear(clonedStartDate) &&
|
||||||
getMonth(date) === getMonth(currentDate) &&
|
getMonth(date) === getMonth(clonedStartDate) &&
|
||||||
getDay(date) === getDay(currentDate)
|
getDay(date) === getDay(clonedStartDate)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (interval === "hour") {
|
if (interval === "hour") {
|
||||||
return (
|
return (
|
||||||
getYear(date) === getYear(currentDate) &&
|
getYear(date) === getYear(clonedStartDate) &&
|
||||||
getMonth(date) === getMonth(currentDate) &&
|
getMonth(date) === getMonth(clonedStartDate) &&
|
||||||
getDay(date) === getDay(currentDate) &&
|
getDay(date) === getDay(clonedStartDate) &&
|
||||||
getHour(date) === getHour(currentDate)
|
getHour(date) === getHour(clonedStartDate)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (interval === "minute") {
|
if (interval === "minute") {
|
||||||
return (
|
return (
|
||||||
getYear(date) === getYear(currentDate) &&
|
getYear(date) === getYear(clonedStartDate) &&
|
||||||
getMonth(date) === getMonth(currentDate) &&
|
getMonth(date) === getMonth(clonedStartDate) &&
|
||||||
getDay(date) === getDay(currentDate) &&
|
getDay(date) === getDay(clonedStartDate) &&
|
||||||
getHour(date) === getHour(currentDate) &&
|
getHour(date) === getHour(clonedStartDate) &&
|
||||||
getMinute(date) === getMinute(currentDate)
|
getMinute(date) === getMinute(clonedStartDate)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -336,7 +331,7 @@ function fillEmptySpotsInTimeline(
|
|||||||
result.push(item);
|
result.push(item);
|
||||||
} else {
|
} else {
|
||||||
result.push({
|
result.push({
|
||||||
date: currentDate.toISOString(),
|
date: clonedStartDate.toISOString(),
|
||||||
count: 0,
|
count: 0,
|
||||||
label: null,
|
label: null,
|
||||||
});
|
});
|
||||||
@@ -344,19 +339,19 @@ function fillEmptySpotsInTimeline(
|
|||||||
|
|
||||||
switch (interval) {
|
switch (interval) {
|
||||||
case "day": {
|
case "day": {
|
||||||
currentDate.setDate(currentDate.getDate() + 1);
|
clonedStartDate.setDate(clonedStartDate.getDate() + 1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "hour": {
|
case "hour": {
|
||||||
currentDate.setHours(currentDate.getHours() + 1);
|
clonedStartDate.setHours(clonedStartDate.getHours() + 1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "minute": {
|
case "minute": {
|
||||||
currentDate.setMinutes(currentDate.getMinutes() + 1);
|
clonedStartDate.setMinutes(clonedStartDate.getMinutes() + 1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "month": {
|
case "month": {
|
||||||
currentDate.setMonth(currentDate.getMonth() + 1);
|
clonedStartDate.setMonth(clonedStartDate.getMonth() + 1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
apps/web/src/server/api/routers/report.ts
Normal file
108
apps/web/src/server/api/routers/report.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
|
||||||
|
import { zChartInput } from "@/utils/validation";
|
||||||
|
import { dateDifferanceInDays, getDaysOldDate } from "@/utils/date";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import {
|
||||||
|
type IChartInput,
|
||||||
|
type IChartBreakdown,
|
||||||
|
type IChartEvent,
|
||||||
|
} from "@/types";
|
||||||
|
import { type Report as DbReport } from "@prisma/client";
|
||||||
|
|
||||||
|
function transform(report: DbReport): IChartInput & { id: string } {
|
||||||
|
return {
|
||||||
|
id: report.id,
|
||||||
|
events: report.events as IChartEvent[],
|
||||||
|
breakdowns: report.breakdowns as IChartBreakdown[],
|
||||||
|
startDate: getDaysOldDate(report.range),
|
||||||
|
endDate: new Date(),
|
||||||
|
chartType: report.chart_type,
|
||||||
|
interval: report.interval,
|
||||||
|
name: report.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reportRouter = createTRPCRouter({
|
||||||
|
get: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(({ input: { id } }) => {
|
||||||
|
return db.report
|
||||||
|
.findUniqueOrThrow({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(transform);
|
||||||
|
}),
|
||||||
|
getDashboard: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
projectId: z.string(),
|
||||||
|
dashboardId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ input: { projectId, dashboardId } }) => {
|
||||||
|
const reports = await db.report.findMany({
|
||||||
|
where: {
|
||||||
|
project_id: projectId,
|
||||||
|
dashboard_id: dashboardId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return reports.map(transform);
|
||||||
|
}),
|
||||||
|
save: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
report: zChartInput,
|
||||||
|
projectId: z.string(),
|
||||||
|
dashboardId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(({ input: { report, projectId, dashboardId } }) => {
|
||||||
|
return db.report.create({
|
||||||
|
data: {
|
||||||
|
project_id: projectId,
|
||||||
|
dashboard_id: dashboardId,
|
||||||
|
name: report.name,
|
||||||
|
events: report.events,
|
||||||
|
interval: report.interval,
|
||||||
|
breakdowns: report.breakdowns,
|
||||||
|
chart_type: report.chartType,
|
||||||
|
range: dateDifferanceInDays(report.endDate, report.startDate),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
reportId: z.string(),
|
||||||
|
report: zChartInput,
|
||||||
|
projectId: z.string(),
|
||||||
|
dashboardId: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(({ input: { report, projectId, dashboardId, reportId } }) => {
|
||||||
|
return db.report.update({
|
||||||
|
where: {
|
||||||
|
id: reportId,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
project_id: projectId,
|
||||||
|
dashboard_id: dashboardId,
|
||||||
|
name: report.name,
|
||||||
|
events: report.events,
|
||||||
|
interval: report.interval,
|
||||||
|
breakdowns: report.breakdowns,
|
||||||
|
chart_type: report.chartType,
|
||||||
|
range: dateDifferanceInDays(report.endDate, report.startDate),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { type zTimeInterval, type zChartBreakdown, type zChartEvent } from "@/utils/validation";
|
import { type zTimeInterval, type zChartBreakdown, type zChartEvent, type zChartInput } from "@/utils/validation";
|
||||||
import { type TooltipProps } from "recharts";
|
import { type TooltipProps } from "recharts";
|
||||||
import { type z } from "zod";
|
import { type z } from "zod";
|
||||||
|
|
||||||
|
export type HtmlProps<T> = React.DetailedHTMLProps<React.HTMLAttributes<T>, T>;
|
||||||
|
|
||||||
|
export type IChartInput = z.infer<typeof zChartInput>
|
||||||
export type IChartEvent = z.infer<typeof zChartEvent>
|
export type IChartEvent = z.infer<typeof zChartEvent>
|
||||||
export type IChartEventFilter = IChartEvent['filters'][number]
|
export type IChartEventFilter = IChartEvent['filters'][number]
|
||||||
export type IChartBreakdown = z.infer<typeof zChartBreakdown>
|
export type IChartBreakdown = z.infer<typeof zChartBreakdown>
|
||||||
@@ -10,4 +13,4 @@ export type IInterval = z.infer<typeof zTimeInterval>
|
|||||||
|
|
||||||
export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
|
export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
|
||||||
payload?: Array<T>
|
payload?: Array<T>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ const getBaseUrl = () => {
|
|||||||
export const api = createTRPCNext<AppRouter>({
|
export const api = createTRPCNext<AppRouter>({
|
||||||
config() {
|
config() {
|
||||||
return {
|
return {
|
||||||
|
queryClientConfig: {
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
refetchOnReconnect: false,
|
||||||
|
refetchOnMount: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
/**
|
/**
|
||||||
* Transformer used for data de-serialization from the server.
|
* Transformer used for data de-serialization from the server.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,4 +2,10 @@ export function getDaysOldDate(days: number) {
|
|||||||
const date = new Date();
|
const date = new Date();
|
||||||
date.setDate(date.getDate() - days);
|
date.setDate(date.getDate() - days);
|
||||||
return date;
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dateDifferanceInDays(date1: Date, date2: Date) {
|
||||||
|
const diffTime = Math.abs(date2.getTime() - date1.getTime());
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ChartType } from "@prisma/client";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const zChartEvent = z.object({
|
export const zChartEvent = z.object({
|
||||||
@@ -19,6 +20,16 @@ export const zChartBreakdown = z.object({
|
|||||||
export const zChartEvents = z.array(zChartEvent);
|
export const zChartEvents = z.array(zChartEvent);
|
||||||
export const zChartBreakdowns = z.array(zChartBreakdown);
|
export const zChartBreakdowns = z.array(zChartBreakdown);
|
||||||
|
|
||||||
export const zChartType = z.enum(["bar", "linear"]);
|
export const zChartType = z.enum(['linear', 'bar', 'pie', 'metric', 'area']);
|
||||||
|
|
||||||
export const zTimeInterval = z.enum(["day", "hour", "month"]);
|
export const zTimeInterval = z.enum(["day", "hour", "month"]);
|
||||||
|
|
||||||
|
export const zChartInput = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
startDate: z.date(),
|
||||||
|
endDate: z.date(),
|
||||||
|
chartType: zChartType,
|
||||||
|
interval: zTimeInterval,
|
||||||
|
events: zChartEvents,
|
||||||
|
breakdowns: zChartBreakdowns,
|
||||||
|
})
|
||||||
@@ -7,6 +7,9 @@
|
|||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "cd apps/web && bun dev"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"bun-types": "latest",
|
"bun-types": "latest",
|
||||||
"semver": "^7.5.4"
|
"semver": "^7.5.4"
|
||||||
|
|||||||
Reference in New Issue
Block a user