save, create and view reports in dashboard

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-10-19 11:56:52 +02:00
parent 2cb6bbfdd3
commit 4576453aef
28 changed files with 686 additions and 403 deletions

View File

@@ -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",
{ {

View File

@@ -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;

View File

@@ -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;

View File

@@ -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")
}

View 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} />
);
}

View 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>
);
}

View File

@@ -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>
)
}

View 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>
)
}

View 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>
</>
);
}

View 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>
</>
);
}

View File

@@ -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"

View File

@@ -0,0 +1,9 @@
import { useQueryParams } from "@/hooks/useQueryParams";
import { z } from "zod";
export const useReportId = () =>
useQueryParams(
z.object({
reportId: z.string().optional(),
}),
);

View File

@@ -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,

View 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>
);
}
}

View File

@@ -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>
); );
} }

View 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]);
}

View 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]);
}

View File

@@ -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>
</>
); );
} }

View File

@@ -0,0 +1 @@
export { default } from "./index";

View 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>
);
}

View File

@@ -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

View File

@@ -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;
} }
} }

View 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),
},
});
}),
});

View File

@@ -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>
} }

View File

@@ -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.
* *

View File

@@ -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));
}
} }

View File

@@ -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,
})

View File

@@ -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"