From 4576453aef4ef5cf584c6734bee9778dad194c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carl-Gerhard=20Lindesva=CC=88rd?= Date: Thu, 19 Oct 2023 11:56:52 +0200 Subject: [PATCH] save, create and view reports in dashboard --- apps/web/.eslintrc.cjs | 3 +- .../migration.sql | 41 ++++ .../migration.sql | 8 + apps/web/prisma/schema.prisma | 53 ++++- apps/web/src/components/Container.tsx | 8 + apps/web/src/components/Navbar.tsx | 10 + .../web/src/components/ReportFilterPicker.tsx | 124 ------------ apps/web/src/components/UserDropdown.tsx | 34 ++++ apps/web/src/components/layouts/Main.tsx | 35 ++++ .../src/components/report/ReportDateRange.tsx | 89 +++++++++ .../report/chart/ReportLineChart.tsx | 40 ++-- .../components/report/hooks/useReportId.ts | 9 + apps/web/src/components/report/reportSlice.ts | 40 ++-- .../components/report/sidebar/ReportSave.tsx | 36 ++++ .../report/sidebar/ReportSidebar.tsx | 2 + apps/web/src/hooks/useQueryParams.ts | 35 ++++ apps/web/src/hooks/useRouterBeforeLeave.ts | 21 ++ apps/web/src/pages/index.tsx | 189 +++--------------- apps/web/src/pages/reports/[reportId].tsx | 1 + apps/web/src/pages/reports/index.tsx | 46 +++++ apps/web/src/server/api/root.ts | 4 +- apps/web/src/server/api/routers/chartMeta.ts | 115 +++++------ apps/web/src/server/api/routers/report.ts | 108 ++++++++++ apps/web/src/types/index.ts | 7 +- apps/web/src/utils/api.ts | 9 + apps/web/src/utils/date.ts | 6 + apps/web/src/utils/validation.ts | 13 +- package.json | 3 + 28 files changed, 686 insertions(+), 403 deletions(-) create mode 100644 apps/web/prisma/migrations/20231018180355_dashboard_and_reports/migration.sql create mode 100644 apps/web/prisma/migrations/20231018181159_add_name_to_report/migration.sql create mode 100644 apps/web/src/components/Container.tsx create mode 100644 apps/web/src/components/Navbar.tsx delete mode 100644 apps/web/src/components/ReportFilterPicker.tsx create mode 100644 apps/web/src/components/UserDropdown.tsx create mode 100644 apps/web/src/components/layouts/Main.tsx create mode 100644 apps/web/src/components/report/ReportDateRange.tsx create mode 100644 apps/web/src/components/report/hooks/useReportId.ts create mode 100644 apps/web/src/components/report/sidebar/ReportSave.tsx create mode 100644 apps/web/src/hooks/useQueryParams.ts create mode 100644 apps/web/src/hooks/useRouterBeforeLeave.ts create mode 100644 apps/web/src/pages/reports/[reportId].tsx create mode 100644 apps/web/src/pages/reports/index.tsx create mode 100644 apps/web/src/server/api/routers/report.ts diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index 41128c0f..2ba00197 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -20,7 +20,8 @@ const config = { "@typescript-eslint/no-unsafe-assignment": "off", "@typescript-eslint/no-unsafe-member-access": "off", "@typescript-eslint/no-unsafe-argument": "off", - + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/no-unsafe-call": "off", "@typescript-eslint/consistent-type-imports": [ "warn", { diff --git a/apps/web/prisma/migrations/20231018180355_dashboard_and_reports/migration.sql b/apps/web/prisma/migrations/20231018180355_dashboard_and_reports/migration.sql new file mode 100644 index 00000000..b07e87bd --- /dev/null +++ b/apps/web/prisma/migrations/20231018180355_dashboard_and_reports/migration.sql @@ -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; diff --git a/apps/web/prisma/migrations/20231018181159_add_name_to_report/migration.sql b/apps/web/prisma/migrations/20231018181159_add_name_to_report/migration.sql new file mode 100644 index 00000000..3aed049e --- /dev/null +++ b/apps/web/prisma/migrations/20231018181159_add_name_to_report/migration.sql @@ -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; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index d40ef1e7..646f9af6 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -31,8 +31,10 @@ model Project { profiles Profile[] clients Client[] - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + reports Report[] + dashboards Dashboard[] @@map("projects") } @@ -97,3 +99,50 @@ model Client { @@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") +} diff --git a/apps/web/src/components/Container.tsx b/apps/web/src/components/Container.tsx new file mode 100644 index 00000000..7bdede15 --- /dev/null +++ b/apps/web/src/components/Container.tsx @@ -0,0 +1,8 @@ +import { type HtmlProps } from "@/types"; +import { cn } from "@/utils/cn"; + +export function Container({className,...props}: HtmlProps) { + return ( +
+ ); +} diff --git a/apps/web/src/components/Navbar.tsx b/apps/web/src/components/Navbar.tsx new file mode 100644 index 00000000..9f1cdd5f --- /dev/null +++ b/apps/web/src/components/Navbar.tsx @@ -0,0 +1,10 @@ +import Link from "next/link"; + +export function Navbar() { + return ( +
+ Dashboards + Reports +
+ ); +} diff --git a/apps/web/src/components/ReportFilterPicker.tsx b/apps/web/src/components/ReportFilterPicker.tsx deleted file mode 100644 index cb6d8ed4..00000000 --- a/apps/web/src/components/ReportFilterPicker.tsx +++ /dev/null @@ -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 ( - - - - - - My Account - - - - - Profile - ⇧⌘P - - - - Billing - ⌘B - - - - Settings - ⌘S - - - - Keyboard shortcuts - ⌘K - - - - - - - Team - - - - - Invite users - - - - - - Email - - - - Message - - - - - More... - - - - - - - New Team - ⌘+T - - - - - - GitHub - - - - Support - - - - API - - - - - Log out - ⇧⌘Q - - - - ) -} \ No newline at end of file diff --git a/apps/web/src/components/UserDropdown.tsx b/apps/web/src/components/UserDropdown.tsx new file mode 100644 index 00000000..378ee829 --- /dev/null +++ b/apps/web/src/components/UserDropdown.tsx @@ -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 ( + + + + + + + + + Profile + + + + Organization + + + + + Logout + + + + + ) +} \ No newline at end of file diff --git a/apps/web/src/components/layouts/Main.tsx b/apps/web/src/components/layouts/Main.tsx new file mode 100644 index 00000000..c2f44a5b --- /dev/null +++ b/apps/web/src/components/layouts/Main.tsx @@ -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 ( + <> + + Create T3 App + + + + +
+ {children} +
+ + ); +} diff --git a/apps/web/src/components/report/ReportDateRange.tsx b/apps/web/src/components/report/ReportDateRange.tsx new file mode 100644 index 00000000..938e8740 --- /dev/null +++ b/apps/web/src/components/report/ReportDateRange.tsx @@ -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 ( + <> + + { + dispatch(changeDateRanges(1)); + }} + > + Today + + { + dispatch(changeDateRanges(7)); + }} + > + 7 days + + { + dispatch(changeDateRanges(14)); + }} + > + 14 days + + { + dispatch(changeDateRanges(30)); + }} + > + 1 month + + { + dispatch(changeDateRanges(90)); + }} + > + 3 month + + { + dispatch(changeDateRanges(180)); + }} + > + 6 month + + { + dispatch(changeDateRanges(356)); + }} + > + 1 year + + +
+ { + dispatch(changeInterval(value as IInterval)); + }} + value={interval} + items={[ + { + label: "Hour", + value: "hour", + }, + { + label: "Day", + value: "day", + }, + { + label: "Month", + value: "month", + }, + ]} + > +
+ + ); +} diff --git a/apps/web/src/components/report/chart/ReportLineChart.tsx b/apps/web/src/components/report/chart/ReportLineChart.tsx index 35dd5a8d..486d67bb 100644 --- a/apps/web/src/components/report/chart/ReportLineChart.tsx +++ b/apps/web/src/components/report/chart/ReportLineChart.tsx @@ -1,7 +1,6 @@ import { api } from "@/utils/api"; import { CartesianGrid, - Legend, Line, LineChart, Tooltip, @@ -10,22 +9,13 @@ import { } from "recharts"; import { ReportLineChartTooltip } from "./ReportLineChartTooltop"; import { useFormatDateInterval } from "@/hooks/useFormatDateInterval"; -import { - type IChartBreakdown, - type IChartEvent, - type IInterval, -} from "@/types"; +import { type IChartInput } from "@/types"; import { getChartColor } from "@/utils/theme"; import { ReportTable } from "./ReportTable"; import { useEffect, useRef, useState } from "react"; import { AutoSizer } from "@/components/AutoSizer"; -type ReportLineChartProps = { - interval: IInterval; - startDate: Date; - endDate: Date; - events: IChartEvent[]; - breakdowns: IChartBreakdown[]; +type ReportLineChartProps = IChartInput & { showTable?: boolean; }; @@ -36,16 +26,20 @@ export function ReportLineChart({ events, breakdowns, showTable, + chartType, + name, }: ReportLineChartProps) { const [visibleSeries, setVisibleSeries] = useState([]); + const chart = api.chartMeta.chart.useQuery( { interval, - chartType: "linear", + chartType, startDate, endDate, events, breakdowns, + name, }, { enabled: events.length > 0, @@ -58,25 +52,23 @@ export function ReportLineChart({ useEffect(() => { if (!ref.current && chart.data) { const max = 20; - + setVisibleSeries( chart.data?.series?.slice(0, max).map((serie) => serie.name) ?? [], - ); - // ref.current = true; - } - }, [chart.data]); - - return ( - <> + ); + // ref.current = true; + } + }, [chart.data]); + + return ( + <> {chart.isSuccess && chart.data?.series?.[0]?.data && ( <> {({ width }) => ( - - {/* */} + } /> - {/* */} + useQueryParams( + z.object({ + reportId: z.string().optional(), + }), + ); diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/web/src/components/report/reportSlice.ts index 0881328f..cd100eac 100644 --- a/apps/web/src/components/report/reportSlice.ts +++ b/apps/web/src/components/report/reportSlice.ts @@ -1,4 +1,5 @@ import { + type IChartInput, type IChartBreakdown, type IChartEvent, type IInterval, @@ -6,32 +7,17 @@ import { import { getDaysOldDate } from "@/utils/date"; import { type PayloadAction, createSlice } from "@reduxjs/toolkit"; -type InitialState = { - events: IChartEvent[]; - breakdowns: IChartBreakdown[]; - interval: IInterval; - startDate: Date; - endDate: Date; -}; +type InitialState = IChartInput; // First approach: define the initial state using that type const initialState: InitialState = { + name: "", + chartType: "linear", startDate: getDaysOldDate(7), endDate: new Date(), interval: "day", - breakdowns: [ - { - id: "A", - name: 'properties.id' - } - ], - events: [ - { - id: "A", - name: "sign_up", - filters: [] - }, - ], + breakdowns: [], + events: [], }; const IDS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"] as const; @@ -40,6 +26,12 @@ export const reportSlice = createSlice({ name: "counter", initialState, reducers: { + reset() { + return initialState + }, + setReport(state, action: PayloadAction) { + return action.payload + }, // Events addEvent: (state, action: PayloadAction>) => { state.events.push({ @@ -111,21 +103,23 @@ export const reportSlice = createSlice({ }, changeDateRanges: (state, action: PayloadAction) => { - if(action.payload === 1) { + if (action.payload === 1) { state.interval = "hour"; - } else if(action.payload <= 30) { + } else if (action.payload <= 30) { state.interval = "day"; } else { state.interval = "month"; } state.startDate = getDaysOldDate(action.payload); state.endDate = new Date(); - } + }, }, }); // Action creators are generated for each case reducer function export const { + reset, + setReport, addEvent, removeEvent, changeEvent, diff --git a/apps/web/src/components/report/sidebar/ReportSave.tsx b/apps/web/src/components/report/sidebar/ReportSave.tsx new file mode 100644 index 00000000..c1431a81 --- /dev/null +++ b/apps/web/src/components/report/sidebar/ReportSave.tsx @@ -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 ; + } else { + return ( + + ); + } +} diff --git a/apps/web/src/components/report/sidebar/ReportSidebar.tsx b/apps/web/src/components/report/sidebar/ReportSidebar.tsx index 8575adc2..cbb8ca23 100644 --- a/apps/web/src/components/report/sidebar/ReportSidebar.tsx +++ b/apps/web/src/components/report/sidebar/ReportSidebar.tsx @@ -1,11 +1,13 @@ import { ReportEvents } from "./ReportEvents"; import { ReportBreakdowns } from "./ReportBreakdowns"; +import { ReportSave } from "./ReportSave"; export function ReportSidebar() { return (
+
); } diff --git a/apps/web/src/hooks/useQueryParams.ts b/apps/web/src/hooks/useQueryParams.ts new file mode 100644 index 00000000..b6b180bc --- /dev/null +++ b/apps/web/src/hooks/useQueryParams.ts @@ -0,0 +1,35 @@ +import { useMemo } from "react"; + +import { useRouter } from "next/router"; +import { type z } from "zod"; + +export function useQueryParams(zod: Z) { + const router = useRouter(); + const value = zod.safeParse(router.query); + + return useMemo(() => { + function setQueryParams(newValue: Partial>) { + return router + .replace({ + pathname: router.pathname, + query: { + ...router.query, + ...newValue, + }, + }) + .catch(() => { + // ignore + }); + } + + if (value.success) { + return { ...value.data, setQueryParams } as z.infer & { + setQueryParams: typeof setQueryParams; + }; + } + return { ...router.query, setQueryParams } as z.infer & { + setQueryParams: typeof setQueryParams; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [router.asPath, value.success]); +} diff --git a/apps/web/src/hooks/useRouterBeforeLeave.ts b/apps/web/src/hooks/useRouterBeforeLeave.ts new file mode 100644 index 00000000..4724c190 --- /dev/null +++ b/apps/web/src/hooks/useRouterBeforeLeave.ts @@ -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]); +} \ No newline at end of file diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx index 041f8495..2cc8d534 100644 --- a/apps/web/src/pages/index.tsx +++ b/apps/web/src/pages/index.tsx @@ -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 { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Combobox } from "@/components/ui/combobox"; -import { useDispatch, useSelector } from "@/redux"; -import { - changeDateRanges, - changeInterval, -} from "@/components/report/reportSlice"; -import { type IInterval } from "@/types"; -import { Avatar, AvatarFallback } from "@/components/ui/avatar"; -import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuShortcut, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { User } from "lucide-react"; -import { DropdownMenuSeparator } from "@radix-ui/react-dropdown-menu"; +import { MainLayout } from "@/components/layouts/Main"; +import { Container } from "@/components/Container"; +import { api } from "@/utils/api"; +import Link from "next/link"; export default function Home() { - const dispatch = useDispatch(); - const interval = useSelector((state) => state.report.interval); - const events = useSelector((state) => state.report.events); - const breakdowns = useSelector((state) => state.report.breakdowns); - const startDate = useSelector((state) => state.report.startDate); - const endDate = useSelector((state) => state.report.endDate); + const reportsQuery = api.report.getDashboard.useQuery({ + projectId: 'f7eabf0c-e0b0-4ac0-940f-1589715b0c3d', + dashboardId: '9227feb4-ad59-40f3-b887-3501685733dd', + }, { + staleTime: 1000 * 60 * 5, + }) + const reports = reportsQuery.data ?? [] return ( - <> - - Create T3 App - - - - -
-
- -
- -
-
- - { - dispatch(changeDateRanges(1)); - }} - > - Today - - { - dispatch(changeDateRanges(1)); - }} - > - 7 days - - { - dispatch(changeDateRanges(14)); - }} - > - 14 days - - { - dispatch(changeDateRanges(30)); - }} - > - 1 month - - { - dispatch(changeDateRanges(90)); - }} - > - 3 month - - { - dispatch(changeDateRanges(180)); - }} - > - 6 month - - { - dispatch(changeDateRanges(356)); - }} - > - 1 year - - -
- { - dispatch(changeInterval(value as IInterval)); - }} - value={interval} - items={[ - { - label: "Hour", - value: "hour", - }, - { - label: "Day", - value: "day", - }, - { - label: "Month", - value: "month", - }, - ]} - > + + + {reports.map((report) => ( +
+ {report.name} +
+
- {startDate && endDate && ( - - )} -
-
- + ))} + + ); } diff --git a/apps/web/src/pages/reports/[reportId].tsx b/apps/web/src/pages/reports/[reportId].tsx new file mode 100644 index 00000000..19f46dd7 --- /dev/null +++ b/apps/web/src/pages/reports/[reportId].tsx @@ -0,0 +1 @@ +export { default } from "./index"; diff --git a/apps/web/src/pages/reports/index.tsx b/apps/web/src/pages/reports/index.tsx new file mode 100644 index 00000000..4490927f --- /dev/null +++ b/apps/web/src/pages/reports/index.tsx @@ -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 ( + +
+ +
+
+
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index 423e5d52..da563494 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -1,6 +1,7 @@ import { exampleRouter } from "@/server/api/routers/example"; import { createTRPCRouter } from "@/server/api/trpc"; import { chartMetaRouter } from "./routers/chartMeta"; +import { reportRouter } from "./routers/report"; /** * This is the primary router for your server. @@ -9,7 +10,8 @@ import { chartMetaRouter } from "./routers/chartMeta"; */ export const appRouter = createTRPCRouter({ example: exampleRouter, - chartMeta: chartMetaRouter + chartMeta: chartMetaRouter, + report: reportRouter, }); // export type definition of API diff --git a/apps/web/src/server/api/routers/chartMeta.ts b/apps/web/src/server/api/routers/chartMeta.ts index c94b49a8..05e74ab4 100644 --- a/apps/web/src/server/api/routers/chartMeta.ts +++ b/apps/web/src/server/api/routers/chartMeta.ts @@ -5,12 +5,7 @@ import { db } from "@/server/db"; import { map, path, pipe, sort, uniq } from "ramda"; import { toDots } from "@/utils/object"; import { Prisma } from "@prisma/client"; -import { - zChartBreakdowns, - zChartEvents, - zChartType, - zTimeInterval, -} from "@/utils/validation"; +import { zChartInput } from "@/utils/validation"; import { type IChartBreakdown, type IChartEvent } from "@/types"; type ResultItem = { @@ -21,17 +16,25 @@ type ResultItem = { function propertyNameToSql(name: string) { if (name.includes(".")) { - return name + const str = name .split(".") .map((item, index) => (index === 0 ? item : `'${item}'`)) .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; } function getEventLegend(event: IChartEvent) { - return `${event.name} (${event.id})` + return `${event.name} (${event.id})`; } function getTotalCount(arr: ResultItem[]) { @@ -84,7 +87,7 @@ async function getChartData({ filters.forEach((filter) => { const { name, value } = filter; if (name.includes(".")) { - where.push(`${propertyNameToSql(name)} = '"${value}"'`); + where.push(`${propertyNameToSql(name)} = '${value}'`); } else { where.push(`${name} = '${value}'`); } @@ -119,9 +122,8 @@ async function getChartData({ GROUP BY ${groupBy.join(", ")} ORDER BY ${orderBy.join(", ")} `; - console.log(sql); - const result = await db.$queryRawUnsafe(sql); + const result = await db.$queryRawUnsafe(sql); // group by sql label const series = result.reduce( @@ -129,7 +131,7 @@ async function getChartData({ // item.label can be null when using breakdowns on a property // that doesn't exist on all events // fallback on event legend - const label = item.label?.trim() ?? getEventLegend(event) + const label = item.label?.trim() ?? getEventLegend(event); if (label) { if (acc[label]) { acc[label]?.push(item); @@ -147,22 +149,19 @@ async function getChartData({ return Object.keys(series).map((key) => { const legend = breakdowns.length ? key : getEventLegend(event); - const data = series[key] ?? [] + const data = series[key] ?? []; return { name: legend, totalCount: getTotalCount(data), - data: fillEmptySpotsInTimeline( - data, - interval, - startDate, - endDate, - ).map((item) => { - return { - label: legend, - count: item.count, - date: new Date(item.date).toISOString(), - }; - }), + data: fillEmptySpotsInTimeline(data, interval, startDate, endDate).map( + (item) => { + return { + label: legend, + count: item.count, + date: new Date(item.date).toISOString(), + }; + }, + ), }; }); } @@ -237,16 +236,7 @@ export const chartMetaRouter = createTRPCRouter({ }), chart: protectedProcedure - .input( - z.object({ - startDate: z.date().nullish(), - endDate: z.date().nullish(), - chartType: zChartType, - interval: zTimeInterval, - events: zChartEvents, - breakdowns: zChartBreakdowns, - }), - ) + .input(zChartInput) .query( async ({ input: { chartType, events, breakdowns, interval, ...input }, @@ -285,12 +275,17 @@ function fillEmptySpotsInTimeline( endDate: Date, ) { const result = []; - const currentDate = new Date(startDate); - currentDate.setHours(2, 0, 0, 0); - const modifiedEndDate = new Date(endDate); - modifiedEndDate.setHours(2, 0, 0, 0); - - while (currentDate.getTime() <= modifiedEndDate.getTime()) { + const clonedStartDate = new Date(startDate); + const clonedEndDate = new Date(endDate); + if(interval === 'hour') { + clonedStartDate.setMinutes(0, 0, 0); + clonedEndDate.setMinutes(0, 0, 0) + } 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 getMonth = (date: Date) => date.getMonth(); const getDay = (date: Date) => date.getDate(); @@ -302,32 +297,32 @@ function fillEmptySpotsInTimeline( if (interval === "month") { return ( - getYear(date) === getYear(currentDate) && - getMonth(date) === getMonth(currentDate) + getYear(date) === getYear(clonedStartDate) && + getMonth(date) === getMonth(clonedStartDate) ); } if (interval === "day") { return ( - getYear(date) === getYear(currentDate) && - getMonth(date) === getMonth(currentDate) && - getDay(date) === getDay(currentDate) + getYear(date) === getYear(clonedStartDate) && + getMonth(date) === getMonth(clonedStartDate) && + getDay(date) === getDay(clonedStartDate) ); } if (interval === "hour") { return ( - getYear(date) === getYear(currentDate) && - getMonth(date) === getMonth(currentDate) && - getDay(date) === getDay(currentDate) && - getHour(date) === getHour(currentDate) + getYear(date) === getYear(clonedStartDate) && + getMonth(date) === getMonth(clonedStartDate) && + getDay(date) === getDay(clonedStartDate) && + getHour(date) === getHour(clonedStartDate) ); } if (interval === "minute") { return ( - getYear(date) === getYear(currentDate) && - getMonth(date) === getMonth(currentDate) && - getDay(date) === getDay(currentDate) && - getHour(date) === getHour(currentDate) && - getMinute(date) === getMinute(currentDate) + getYear(date) === getYear(clonedStartDate) && + getMonth(date) === getMonth(clonedStartDate) && + getDay(date) === getDay(clonedStartDate) && + getHour(date) === getHour(clonedStartDate) && + getMinute(date) === getMinute(clonedStartDate) ); } }); @@ -336,7 +331,7 @@ function fillEmptySpotsInTimeline( result.push(item); } else { result.push({ - date: currentDate.toISOString(), + date: clonedStartDate.toISOString(), count: 0, label: null, }); @@ -344,19 +339,19 @@ function fillEmptySpotsInTimeline( switch (interval) { case "day": { - currentDate.setDate(currentDate.getDate() + 1); + clonedStartDate.setDate(clonedStartDate.getDate() + 1); break; } case "hour": { - currentDate.setHours(currentDate.getHours() + 1); + clonedStartDate.setHours(clonedStartDate.getHours() + 1); break; } case "minute": { - currentDate.setMinutes(currentDate.getMinutes() + 1); + clonedStartDate.setMinutes(clonedStartDate.getMinutes() + 1); break; } case "month": { - currentDate.setMonth(currentDate.getMonth() + 1); + clonedStartDate.setMonth(clonedStartDate.getMonth() + 1); break; } } diff --git a/apps/web/src/server/api/routers/report.ts b/apps/web/src/server/api/routers/report.ts new file mode 100644 index 00000000..6fe32894 --- /dev/null +++ b/apps/web/src/server/api/routers/report.ts @@ -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), + }, + }); + }), +}); diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index 882187dd..dcba7ba2 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -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 z } from "zod"; +export type HtmlProps = React.DetailedHTMLProps, T>; + +export type IChartInput = z.infer export type IChartEvent = z.infer export type IChartEventFilter = IChartEvent['filters'][number] export type IChartBreakdown = z.infer @@ -10,4 +13,4 @@ export type IInterval = z.infer export type IToolTipProps = Omit, 'payload'> & { payload?: Array -} \ No newline at end of file +} diff --git a/apps/web/src/utils/api.ts b/apps/web/src/utils/api.ts index c4078648..6191832a 100644 --- a/apps/web/src/utils/api.ts +++ b/apps/web/src/utils/api.ts @@ -21,6 +21,15 @@ const getBaseUrl = () => { export const api = createTRPCNext({ config() { return { + queryClientConfig: { + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + } + } + }, /** * Transformer used for data de-serialization from the server. * diff --git a/apps/web/src/utils/date.ts b/apps/web/src/utils/date.ts index 8cd4c276..2586f573 100644 --- a/apps/web/src/utils/date.ts +++ b/apps/web/src/utils/date.ts @@ -2,4 +2,10 @@ export function getDaysOldDate(days: number) { const date = new Date(); date.setDate(date.getDate() - days); 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)); +} } \ No newline at end of file diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts index a2f954e2..2613e833 100644 --- a/apps/web/src/utils/validation.ts +++ b/apps/web/src/utils/validation.ts @@ -1,3 +1,4 @@ +import { ChartType } from "@prisma/client"; import { z } from "zod"; export const zChartEvent = z.object({ @@ -19,6 +20,16 @@ export const zChartBreakdown = z.object({ export const zChartEvents = z.array(zChartEvent); 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 zChartInput = z.object({ + name: z.string(), + startDate: z.date(), + endDate: z.date(), + chartType: zChartType, + interval: zTimeInterval, + events: zChartEvents, + breakdowns: zChartBreakdowns, +}) \ No newline at end of file diff --git a/package.json b/package.json index e8d93c34..cc33a4e9 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "license": "ISC", "module": "index.ts", "type": "module", + "scripts": { + "dev": "cd apps/web && bun dev" + }, "devDependencies": { "bun-types": "latest", "semver": "^7.5.4"