diff --git a/apps/web/.eslintrc.cjs b/apps/web/.eslintrc.cjs index f15a4d58..41128c0f 100644 --- a/apps/web/.eslintrc.cjs +++ b/apps/web/.eslintrc.cjs @@ -15,6 +15,11 @@ const config = { // Feel free to reconfigure them to your own preference. "@typescript-eslint/array-type": "off", "@typescript-eslint/consistent-type-definitions": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-argument": "off", "@typescript-eslint/consistent-type-imports": [ "warn", diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 00000000..b25b509f --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/styles/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/utils/cn" + } +} \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index de4ba97a..8aa78d5f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,12 @@ "@mixan/types": "^0.0.2-alpha", "@next-auth/prisma-adapter": "^1.0.7", "@prisma/client": "^5.1.1", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-slot": "^1.0.2", + "@reduxjs/toolkit": "^1.9.7", "@t3-oss/env-nextjs": "^0.7.0", "@tanstack/react-query": "^4.32.6", "@trpc/client": "^10.37.1", @@ -21,18 +27,28 @@ "@trpc/react-query": "^10.37.1", "@trpc/server": "^10.37.1", "bcrypt": "^5.1.1", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "cmdk": "^0.2.0", + "lucide-react": "^0.286.0", "next": "^13.5.4", "next-auth": "^4.23.0", + "ramda": "^0.29.1", "random-animal-name": "^0.1.1", "react": "18.2.0", "react-dom": "18.2.0", + "react-redux": "^8.1.3", + "recharts": "^2.8.0", "superjson": "^1.13.1", + "tailwind-merge": "^1.14.0", + "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4" }, "devDependencies": { "@types/bcrypt": "^5.0.0", "@types/eslint": "^8.44.2", "@types/node": "^18.16.0", + "@types/ramda": "^0.29.6", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", "@typescript-eslint/eslint-plugin": "^6.3.0", diff --git a/apps/web/src/components/ReportFilterPicker.tsx b/apps/web/src/components/ReportFilterPicker.tsx new file mode 100644 index 00000000..cb6d8ed4 --- /dev/null +++ b/apps/web/src/components/ReportFilterPicker.tsx @@ -0,0 +1,124 @@ +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/report/chart/ReportLineChart.tsx b/apps/web/src/components/report/chart/ReportLineChart.tsx new file mode 100644 index 00000000..bf20dc1f --- /dev/null +++ b/apps/web/src/components/report/chart/ReportLineChart.tsx @@ -0,0 +1,90 @@ +import { api } from "@/utils/api"; +import { + CartesianGrid, + Legend, + Line, + LineChart, + Tooltip, + XAxis, + YAxis, +} from "recharts"; +import { ReportLineChartTooltip } from "./ReportLineChartTooltop"; +import { useFormatDateInterval } from "@/hooks/useFormatDateInterval"; +import { + type IChartBreakdown, + type IChartEvent, + type IInterval, +} from "@/types"; +import { getChartColor } from "@/utils/theme"; +import { ReportTable } from "./ReportTable"; + +export function ReportLineChart({ + interval, + startDate, + endDate, + events, + breakdowns, +}: { + interval: IInterval; + startDate: Date; + endDate: Date; + events: IChartEvent[]; + breakdowns: IChartBreakdown[]; +}) { + const chart = api.chartMeta.chart.useQuery( + { + interval, + chartType: "linear", + startDate, + endDate, + events, + breakdowns, + }, + { + enabled: events.length > 0, + }, + ); + + const formatDate = useFormatDateInterval(interval); + + return ( + <> + {chart.isSuccess && chart.data?.series?.[0]?.data && ( + <> + + + + } /> + {/* */} + + { + return formatDate(m); + }} + tickLine={false} + allowDuplicatedCategory={false} + /> + {chart.data?.series.slice(0, 5).map((serie, index) => { + const key = serie.name; + const strokeColor = getChartColor(index) + return ( + + ); + })} + + + + )} + + ); +} diff --git a/apps/web/src/components/report/chart/ReportLineChartTooltop.tsx b/apps/web/src/components/report/chart/ReportLineChartTooltop.tsx new file mode 100644 index 00000000..a45208c8 --- /dev/null +++ b/apps/web/src/components/report/chart/ReportLineChartTooltop.tsx @@ -0,0 +1,54 @@ +import { type IToolTipProps } from "@/types"; + +type ReportLineChartTooltipProps = IToolTipProps<{ + color: string; + value: number; + payload: { + date: Date; + count: number; + label: string; + }; +}> + +export function ReportLineChartTooltip({ + active, + payload, +}: ReportLineChartTooltipProps) { + if (!active || !payload) { + return null; + } + + if (!payload.length) { + return null; + } + + + const limit = 3; + const sorted = payload.slice(0).sort((a, b) => b.value - a.value); + const visible = sorted.slice(0, limit); + const hidden = sorted.slice(limit); + + return ( +
+ {visible.map((item) => { + return ( +
+
+
+
+ {item.payload.label} +
+
{item.payload.count}
+
+
+ ); + })} + {hidden.length > 0 && ( +
and {hidden.length} more...
+ )} +
+ ); +} diff --git a/apps/web/src/components/report/chart/ReportTable.tsx b/apps/web/src/components/report/chart/ReportTable.tsx new file mode 100644 index 00000000..878e6e12 --- /dev/null +++ b/apps/web/src/components/report/chart/ReportTable.tsx @@ -0,0 +1,72 @@ +import * as React from "react"; +import { type RouterOutputs } from "@/utils/api"; +import { useFormatDateInterval } from "@/hooks/useFormatDateInterval"; +import { useSelector } from "@/redux"; +import { Checkbox } from "@/components/ui/checkbox"; +import { getChartColor } from "@/utils/theme"; + +export function ReportTable({ + data, +}: { + data: RouterOutputs["chartMeta"]["chart"]; +}) { + const interval = useSelector((state) => state.report.interval); + const formatDate = useFormatDateInterval(interval); + + return ( +
+ {/* Labels */} +
+
Name
+ {data.series.map((serie, index) => { + const checked = index < 5; + return ( +
+ + {serie.name} +
+ ); + })} +
+ + {/* ScrollView for all values */} +
+ {/* Header */} +
+ {data.series[0]?.data.map((serie, index) => ( +
+ {formatDate(serie.date)} +
+ ))} +
+ + {/* Values */} + {data.series.map((serie, index) => { + return ( +
+ {serie.data.map((item) => { + return ( +
+ {item.count} +
+ ); + })} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/report/reportSlice.ts b/apps/web/src/components/report/reportSlice.ts new file mode 100644 index 00000000..43acab8d --- /dev/null +++ b/apps/web/src/components/report/reportSlice.ts @@ -0,0 +1,132 @@ +import { + type IChartBreakdown, + type IChartEvent, + type IInterval, +} from "@/types"; +import { type PayloadAction, createSlice } from "@reduxjs/toolkit"; + +type InitialState = { + events: IChartEvent[]; + breakdowns: IChartBreakdown[]; + interval: IInterval; + startDate: Date; + endDate: Date; +}; + +// First approach: define the initial state using that type +const initialState: InitialState = { + startDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7), + endDate: new Date(), + interval: "day", + breakdowns: [ + { + id: "A", + name: "properties.params.title", + }, + ], + events: [ + { + id: "A", + displayName: "screen_view (0)", + name: "screen_view", + filters: [ + { + id: "1", + name: "properties.route", + value: "RecipeDetails", + }, + ], + }, + ], +}; + +const IDS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"] as const; + +export const reportSlice = createSlice({ + name: "counter", + initialState, + reducers: { + // Events + addEvent: (state, action: PayloadAction>) => { + state.events.push({ + id: IDS[state.events.length]!, + ...action.payload, + }); + }, + removeEvent: ( + state, + action: PayloadAction<{ + id: string; + }>, + ) => { + state.events = state.events.filter( + (event) => event.id !== action.payload.id, + ); + }, + changeEvent: (state, action: PayloadAction) => { + state.events = state.events.map((event) => { + if (event.id === action.payload.id) { + return action.payload; + } + return event; + }); + }, + + // Breakdowns + addBreakdown: ( + state, + action: PayloadAction>, + ) => { + state.breakdowns.push({ + id: IDS[state.breakdowns.length]!, + ...action.payload, + }); + }, + removeBreakdown: ( + state, + action: PayloadAction<{ + id: string; + }>, + ) => { + state.breakdowns = state.breakdowns.filter( + (event) => event.id !== action.payload.id, + ); + }, + changeBreakdown: (state, action: PayloadAction) => { + state.breakdowns = state.breakdowns.map((breakdown) => { + if (breakdown.id === action.payload.id) { + return action.payload; + } + return breakdown; + }); + }, + + // Interval + changeInterval: (state, action: PayloadAction) => { + state.interval = action.payload; + }, + + // Date range + changeStartDate: (state, action: PayloadAction) => { + state.startDate = action.payload; + }, + + // Date range + changeEndDate: (state, action: PayloadAction) => { + state.endDate = action.payload; + }, + }, +}); + +// Action creators are generated for each case reducer function +export const { + addEvent, + removeEvent, + changeEvent, + addBreakdown, + removeBreakdown, + changeBreakdown, + changeInterval, +} = reportSlice.actions; + +export default reportSlice.reducer; diff --git a/apps/web/src/components/report/sidebar/ReportBreakdownMore.tsx b/apps/web/src/components/report/sidebar/ReportBreakdownMore.tsx new file mode 100644 index 00000000..324baaf1 --- /dev/null +++ b/apps/web/src/components/report/sidebar/ReportBreakdownMore.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { Filter, MoreHorizontal, Tags, Trash } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +export type ReportBreakdownMoreProps = { + onClick: (action: 'remove') => void +} + +export function ReportBreakdownMore({ onClick }: ReportBreakdownMoreProps) { + const [open, setOpen] = React.useState(false) + + return ( + + + + + + + onClick('remove')}> + + Delete + ⌘⌫ + + + + + ) +} diff --git a/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx b/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx new file mode 100644 index 00000000..e03e7947 --- /dev/null +++ b/apps/web/src/components/report/sidebar/ReportBreakdowns.tsx @@ -0,0 +1,77 @@ +import { api } from "@/utils/api"; +import { Combobox } from "@/components/ui/combobox"; +import { useDispatch, useSelector } from "@/redux"; +import { addBreakdown, changeBreakdown, removeBreakdown } from "../reportSlice"; +import { type ReportEventMoreProps } from "./ReportEventMore"; +import { type IChartBreakdown } from "@/types"; +import { ReportBreakdownMore } from "./ReportBreakdownMore"; + +export function ReportBreakdowns() { + const selectedBreakdowns = useSelector((state) => state.report.breakdowns); + const dispatch = useDispatch(); + const propertiesQuery = api.chartMeta.properties.useQuery(); + const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({ + value: item, + label: item, + })); + + const handleMore = (breakdown: IChartBreakdown) => { + const callback: ReportEventMoreProps["onClick"] = (action) => { + switch (action) { + case "remove": { + return dispatch(removeBreakdown(breakdown)); + } + } + }; + + return callback; + }; + + return ( +
+

Breakdown

+
+ {selectedBreakdowns.map((item, index) => { + return ( +
+
+
+ {index} +
+ { + dispatch( + changeBreakdown({ + ...item, + name: value, + }), + ); + }} + items={propertiesCombobox} + placeholder="Select..." + /> + +
+
+ ); + })} + + {selectedBreakdowns.length === 0 && ( + { + dispatch( + addBreakdown({ + name: value, + }), + ); + }} + items={propertiesCombobox} + placeholder="Select breakdown" + /> + )} +
+
+ ); +} diff --git a/apps/web/src/components/report/sidebar/ReportEventFilters.tsx b/apps/web/src/components/report/sidebar/ReportEventFilters.tsx new file mode 100644 index 00000000..2aac0722 --- /dev/null +++ b/apps/web/src/components/report/sidebar/ReportEventFilters.tsx @@ -0,0 +1,181 @@ +import { api } from "@/utils/api"; +import { type IChartEvent } from "@/types"; +import { + CreditCard, + SlidersHorizontal, + Trash, +} from "lucide-react"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { type Dispatch } from "react"; +import { RenderDots } from "@/components/ui/RenderDots"; +import { useDispatch } from "@/redux"; +import { changeEvent } from "../reportSlice"; +import { Combobox } from "@/components/ui/combobox"; +import { Button } from "@/components/ui/button"; + +type ReportEventFiltersProps = { + event: IChartEvent; + isCreating: boolean; + setIsCreating: Dispatch; +}; + +export function ReportEventFilters({ + event, + isCreating, + setIsCreating, +}: ReportEventFiltersProps) { + const dispatch = useDispatch(); + const propertiesQuery = api.chartMeta.properties.useQuery( + { + event: event.name, + }, + { + enabled: !!event.name, + }, + ); + + return ( +
+
+ {event.filters.map((filter) => { + return ; + })} + + + + + Such emptyness 🤨 + + {propertiesQuery.data?.map((item) => ( + { + setIsCreating(false); + dispatch( + changeEvent({ + ...event, + filters: [ + ...event.filters, + { + id: (event.filters.length + 1).toString(), + name: item, + value: "", + }, + ], + }), + ); + }} + > + + {item} + + ))} + + + + +
+
+ ); +} + +type FilterProps = { + event: IChartEvent; + filter: IChartEvent["filters"][number]; +}; + +function Filter({ filter, event }: FilterProps) { + const dispatch = useDispatch(); + const potentialValues = api.chartMeta.values.useQuery({ + event: event.name, + property: filter.name, + }); + + const valuesCombobox = + potentialValues.data?.values?.map((item) => ({ + value: item, + label: item, + })) ?? []; + + const removeFilter = () => { + dispatch( + changeEvent({ + ...event, + filters: event.filters.filter((item) => item.id !== filter.id), + }), + ); + }; + + const changeFilter = (value: string) => { + dispatch( + changeEvent({ + ...event, + filters: event.filters.map((item) => { + if (item.id === filter.id) { + return { + ...item, + value, + }; + } + + return item; + }), + }), + ); + }; + + return ( +
+
+
+ +
+ {filter.name} + +
+ {/* { + return fn(filter.value) + // + }} /> */} + + {/* { + dispatch( + changeEvent({ + ...event, + filters: event.filters.map((item) => { + if (item.id === filter.id) { + return { + ...item, + value: e.currentTarget.value, + }; + } + + return item; + }), + }), + ); + }} + /> */} +
+ ); +} diff --git a/apps/web/src/components/report/sidebar/ReportEventMore.tsx b/apps/web/src/components/report/sidebar/ReportEventMore.tsx new file mode 100644 index 00000000..e185ae9a --- /dev/null +++ b/apps/web/src/components/report/sidebar/ReportEventMore.tsx @@ -0,0 +1,68 @@ +import * as React from "react" +import { Filter, MoreHorizontal, Tags, Trash } from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" + +const labels = [ + "feature", + "bug", + "enhancement", + "documentation", + "design", + "question", + "maintenance", +] + +export type ReportEventMoreProps = { + onClick: (action: 'createFilter' | 'remove') => void +} + +export function ReportEventMore({ onClick }: ReportEventMoreProps) { + const [open, setOpen] = React.useState(false) + + return ( + + + + + + + + onClick('createFilter')}> + + Add filter + + + onClick('remove')}> + + Delete + ⌘⌫ + + + + + ) +} diff --git a/apps/web/src/components/report/sidebar/ReportEvents.tsx b/apps/web/src/components/report/sidebar/ReportEvents.tsx new file mode 100644 index 00000000..fb4971cd --- /dev/null +++ b/apps/web/src/components/report/sidebar/ReportEvents.tsx @@ -0,0 +1,85 @@ +import { api } from "@/utils/api"; +import { Combobox } from "@/components/ui/combobox"; +import { useDispatch, useSelector } from "@/redux"; +import { addEvent, changeEvent, removeEvent } from "../reportSlice"; +import { ReportEventFilters } from "./ReportEventFilters"; +import { useState } from "react"; +import { ReportEventMore, type ReportEventMoreProps } from "./ReportEventMore"; +import { type IChartEvent } from "@/types"; + +export function ReportEvents() { + const [isCreating, setIsCreating] = useState(false); + const selectedEvents = useSelector((state) => state.report.events); + const dispatch = useDispatch(); + const eventsQuery = api.chartMeta.events.useQuery(); + const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({ + value: item.name, + label: item.name, + })); + + const handleMore = (event: IChartEvent) => { + const callback: ReportEventMoreProps["onClick"] = (action) => { + switch (action) { + case "createFilter": { + return setIsCreating(true); + } + case "remove": { + return dispatch(removeEvent(event)); + } + } + }; + + return callback; + }; + + return ( +
+

Events

+
+ {selectedEvents.map((event, index) => { + return ( +
+
+
{index}
+ { + dispatch( + changeEvent({ + ...event, + name: value, + filters: [], + }), + ); + }} + items={eventsCombobox} + placeholder="Select event" + /> + +
+ +
+ ); + })} + + { + dispatch( + addEvent({ + displayName: `${value} (${selectedEvents.length})`, + name: value, + filters: [], + }), + ); + }} + items={eventsCombobox} + placeholder="Select event" + /> +
+
+ ); +} diff --git a/apps/web/src/components/report/sidebar/ReportSidebar.tsx b/apps/web/src/components/report/sidebar/ReportSidebar.tsx new file mode 100644 index 00000000..8575adc2 --- /dev/null +++ b/apps/web/src/components/report/sidebar/ReportSidebar.tsx @@ -0,0 +1,11 @@ +import { ReportEvents } from "./ReportEvents"; +import { ReportBreakdowns } from "./ReportBreakdowns"; + +export function ReportSidebar() { + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/components/ui/RenderDots.tsx b/apps/web/src/components/ui/RenderDots.tsx new file mode 100644 index 00000000..dfa8577c --- /dev/null +++ b/apps/web/src/components/ui/RenderDots.tsx @@ -0,0 +1,21 @@ +import { cn } from "@/utils/cn"; +import { ChevronRight } from "lucide-react"; + +interface RenderDotsProps extends React.HTMLAttributes { + children: string; +} + +export function RenderDots({ children, className, ...props }: RenderDotsProps) { + return ( +
+ {children.split(".").map((str, index) => { + return ( +
+ {index !== 0 && } + {str} +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/ui/badge.tsx b/apps/web/src/components/ui/badge.tsx new file mode 100644 index 00000000..47bd6a63 --- /dev/null +++ b/apps/web/src/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/utils/cn" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/apps/web/src/components/ui/button.tsx b/apps/web/src/components/ui/button.tsx new file mode 100644 index 00000000..9c2f5082 --- /dev/null +++ b/apps/web/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/utils/cn" + +const buttonVariants = cva( + "inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input bg-background hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-10 px-4 py-2", + sm: "h-9 rounded-md px-3", + lg: "h-11 rounded-md px-8", + icon: "h-10 w-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/apps/web/src/components/ui/checkbox.tsx b/apps/web/src/components/ui/checkbox.tsx new file mode 100644 index 00000000..a1a5ef1e --- /dev/null +++ b/apps/web/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/utils/cn" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/apps/web/src/components/ui/combobox-multi.tsx b/apps/web/src/components/ui/combobox-multi.tsx new file mode 100644 index 00000000..0d4fdf37 --- /dev/null +++ b/apps/web/src/components/ui/combobox-multi.tsx @@ -0,0 +1,118 @@ +import * as React from "react"; +import { X } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { + Command, + CommandGroup, + CommandItem, +} from "@/components/ui/command"; +import { Command as CommandPrimitive } from "cmdk"; + +type Framework = Record<"value" | "label", string>; + +type ComboboxMultiProps = { + selected: Framework[]; + setSelected: React.Dispatch>; + items: Framework[]; +} + +export function ComboboxMulti({ items, selected, setSelected, ...props }: ComboboxMultiProps) { + const inputRef = React.useRef(null); + const [open, setOpen] = React.useState(false); + const [inputValue, setInputValue] = React.useState(""); + + const handleUnselect = React.useCallback((framework: Framework) => { + setSelected(prev => prev.filter(s => s.value !== framework.value)); + }, []); + + const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => { + const input = inputRef.current + if (input) { + if (e.key === "Delete" || e.key === "Backspace") { + if (input.value === "") { + setSelected(prev => { + const newSelected = [...prev]; + newSelected.pop(); + return newSelected; + }) + } + } + // This is not a default behaviour of the field + if (e.key === "Escape") { + input.blur(); + } + } + }, []); + + const selectables = items.filter(framework => !selected.includes(framework)); + + return ( + +
+
+ {selected.map((framework) => { + return ( + + {framework.label} + + + ) + })} + {/* Avoid having the "Search" Icon */} + setOpen(false)} + onFocus={() => setOpen(true)} + placeholder="Select frameworks..." + className="ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1" + /> +
+
+
+ {open && selectables.length > 0 ? +
+ + {selectables.map((framework) => { + return ( + { + e.preventDefault(); + e.stopPropagation(); + }} + onSelect={(value) => { + setInputValue("") + setSelected(prev => [...prev, framework]) + }} + className={"cursor-pointer"} + > + {framework.label} + + ); + })} + +
+ : null} +
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx new file mode 100644 index 00000000..f16ff41c --- /dev/null +++ b/apps/web/src/components/ui/combobox.tsx @@ -0,0 +1,84 @@ +import * as React from "react"; +import { Check, ChevronsUpDown } from "lucide-react"; + +import { cn } from "@/utils/cn"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +type ComboboxProps = { + placeholder: string; + items: Array<{ + value: string; + label: string; + }>; + value: string; + onChange: (value: string) => void; +}; + +export function Combobox({ + placeholder, + items, + value, + onChange, +}: ComboboxProps) { + const [open, setOpen] = React.useState(false); + + function find(value: string) { + return items.find( + (item) => item.value.toLowerCase() === value.toLowerCase(), + ); + } + + return ( + + + + + + + + Nothing selected + + {items.map((item) => ( + { + const value = find(currentValue)?.value ?? ""; + onChange(value); + setOpen(false); + }} + > + + {item.label} + + ))} + + + + + ); +} diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx new file mode 100644 index 00000000..1131b8b7 --- /dev/null +++ b/apps/web/src/components/ui/command.tsx @@ -0,0 +1,153 @@ +import * as React from "react" +import { type DialogProps } from "@radix-ui/react-dialog" +import { Command as CommandPrimitive } from "cmdk" +import { Search } from "lucide-react" + +import { cn } from "@/utils/cn" +import { Dialog, DialogContent } from "@/components/ui/dialog" + +const Command = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Command.displayName = CommandPrimitive.displayName + +type CommandDialogProps = DialogProps + +const CommandDialog = ({ children, ...props }: CommandDialogProps) => { + return ( + + + + {children} + + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( +
+ + +
+)) + +CommandInput.displayName = CommandPrimitive.Input.displayName + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandList.displayName = CommandPrimitive.List.displayName + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>((props, ref) => ( + +)) + +CommandEmpty.displayName = CommandPrimitive.Empty.displayName + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandGroup.displayName = CommandPrimitive.Group.displayName + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = CommandPrimitive.Separator.displayName + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) + +CommandItem.displayName = CommandPrimitive.Item.displayName + +const CommandShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx new file mode 100644 index 00000000..854dc00d --- /dev/null +++ b/apps/web/src/components/ui/dialog.tsx @@ -0,0 +1,120 @@ +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { X } from "lucide-react" + +import { cn } from "@/utils/cn" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 00000000..9db71511 --- /dev/null +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,198 @@ +import * as React from "react" +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" +import { Check, ChevronRight, Circle } from "lucide-react" + +import { cn } from "@/utils/cn" + +const DropdownMenu = DropdownMenuPrimitive.Root + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger + +const DropdownMenuGroup = DropdownMenuPrimitive.Group + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal + +const DropdownMenuSub = DropdownMenuPrimitive.Sub + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/apps/web/src/components/ui/input.tsx b/apps/web/src/components/ui/input.tsx new file mode 100644 index 00000000..2fa39d97 --- /dev/null +++ b/apps/web/src/components/ui/input.tsx @@ -0,0 +1,24 @@ +import * as React from "react" + +import { cn } from "@/utils/cn" + +export type InputProps = React.InputHTMLAttributes + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/apps/web/src/components/ui/popover.tsx b/apps/web/src/components/ui/popover.tsx new file mode 100644 index 00000000..1e5cdd97 --- /dev/null +++ b/apps/web/src/components/ui/popover.tsx @@ -0,0 +1,29 @@ +import * as React from "react" +import * as PopoverPrimitive from "@radix-ui/react-popover" + +import { cn } from "@/utils/cn" + +const Popover = PopoverPrimitive.Root + +const PopoverTrigger = PopoverPrimitive.Trigger + +const PopoverContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)) +PopoverContent.displayName = PopoverPrimitive.Content.displayName + +export { Popover, PopoverTrigger, PopoverContent } diff --git a/apps/web/src/components/ui/radio-group.tsx b/apps/web/src/components/ui/radio-group.tsx new file mode 100644 index 00000000..585853ae --- /dev/null +++ b/apps/web/src/components/ui/radio-group.tsx @@ -0,0 +1,32 @@ +import * as React from "react" + +import { cn } from "@/utils/cn" + +export type RadioGroupProps = React.InputHTMLAttributes +export type RadioGroupItemProps = React.InputHTMLAttributes + +const RadioGroup = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( +
+ ) + } +) + +const RadioGroupItem = React.forwardRef(({className, ...props}, ref) => { + return ( + -
- ); -} diff --git a/apps/web/src/redux/index.ts b/apps/web/src/redux/index.ts new file mode 100644 index 00000000..f0c5d4be --- /dev/null +++ b/apps/web/src/redux/index.ts @@ -0,0 +1,18 @@ +import reportSlice from '@/components/report/reportSlice' +import { configureStore } from '@reduxjs/toolkit' +import { type TypedUseSelectorHook, useDispatch as useBaseDispatch, useSelector as useBaseSelector } from 'react-redux' + +const store = configureStore({ + reducer: { + report: reportSlice + }, +}) + +export type RootState = ReturnType + +export type AppDispatch = typeof store.dispatch +export const useDispatch: () => AppDispatch = useBaseDispatch +export const useSelector: TypedUseSelectorHook = useBaseSelector + + +export default store \ No newline at end of file diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts index 0a794406..423e5d52 100644 --- a/apps/web/src/server/api/root.ts +++ b/apps/web/src/server/api/root.ts @@ -1,5 +1,6 @@ import { exampleRouter } from "@/server/api/routers/example"; import { createTRPCRouter } from "@/server/api/trpc"; +import { chartMetaRouter } from "./routers/chartMeta"; /** * This is the primary router for your server. @@ -8,6 +9,7 @@ import { createTRPCRouter } from "@/server/api/trpc"; */ export const appRouter = createTRPCRouter({ example: exampleRouter, + chartMeta: chartMetaRouter }); // 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 new file mode 100644 index 00000000..c49a7672 --- /dev/null +++ b/apps/web/src/server/api/routers/chartMeta.ts @@ -0,0 +1,352 @@ +import { z } from "zod"; + +import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc"; +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 { type IChartBreakdown, type IChartEvent } from "@/types"; + +type ResultItem = { + label: string | null; + count: number; + date: string; +}; + +function propertyNameToSql(name: string) { + if (name.includes(".")) { + return name + .split(".") + .map((item, index) => (index === 0 ? item : `'${item}'`)) + .join("->"); + } + + return name; +} + +export const config = { + api: { + responseLimit: false, + }, +}; + +async function getChartData({ + chartType, + event, + breakdowns, + interval, + startDate, + endDate, +}: { + chartType: string; + event: IChartEvent; + breakdowns: IChartBreakdown[]; + interval: string; + startDate: Date; + endDate: Date; +}) { + const select = [`count(*)::int as count`]; + const where = []; + const groupBy = []; + const orderBy = []; + + switch (chartType) { + case "bar": { + orderBy.push("count DESC"); + break; + } + case "linear": { + select.push(`date_trunc('${interval}', "createdAt") as date`); + groupBy.push("date"); + orderBy.push("date"); + break; + } + } + + if (event) { + const { name, filters } = event; + where.push(`name = '${name}'`); + if (filters.length > 0) { + filters.forEach((filter) => { + const { name, value } = filter; + if (name.includes(".")) { + where.push(`${propertyNameToSql(name)} = '"${value}"'`); + } else { + where.push(`${name} = '${value}'`); + } + }); + } + } + + if (breakdowns.length) { + const breakdown = breakdowns[0]; + if (breakdown) { + select.push(`${propertyNameToSql(breakdown.name)} as label`); + groupBy.push(`label`); + } + } else { + if (event.name) { + select.push(`'${event.name}' as label`); + } + } + + if (startDate) { + where.push(`"createdAt" >= '${startDate.toISOString()}'`); + } + + if (endDate) { + where.push(`"createdAt" <= '${endDate.toISOString()}'`); + } + + const sql = ` + SELECT ${select.join(", ")} + FROM events + WHERE ${where.join(" AND ")} + GROUP BY ${groupBy.join(", ")} + ORDER BY ${orderBy.join(", ")} + `; + console.log(sql); + + const result = await db.$queryRawUnsafe(sql); + const series = result.reduce( + (acc, item) => { + const label = item.label?.trim() ?? event.displayName; + if (label) { + if (acc[label]) { + acc[label]?.push(item); + } else { + acc[label] = [item]; + } + } + + return { + ...acc, + }; + }, + {} as Record, + ); + + return Object.keys(series).map((key) => { + return { + name: breakdowns.length ? key ?? "break a leg" : event.displayName, + data: fillEmptySpotsInTimeline( + series[key] ?? [], + interval, + startDate, + endDate, + ).map((item) => { + return { + ...item, + label: breakdowns.length ? key ?? "break a leg" : event.displayName, + date: new Date(item.date).toISOString(), + }; + }), + }; + }); +} + +export const chartMetaRouter = createTRPCRouter({ + events: protectedProcedure + // .input(z.object()) + .query(async ({ input }) => { + const events = await db.event.findMany({ + take: 500, + distinct: ["name"], + }); + + return events; + }), + + properties: protectedProcedure + .input(z.object({ event: z.string() }).optional()) + .query(async ({ input }) => { + const events = await db.event.findMany({ + take: 500, + where: { + ...(input?.event + ? { + name: input.event, + } + : {}), + }, + }); + + const properties = events.reduce((acc, event) => { + const properties = event as Record; + const dotNotation = toDots(properties); + return [...acc, ...Object.keys(dotNotation)]; + }, [] as string[]); + + return pipe( + sort((a, b) => a.length - b.length), + uniq, + )(properties); + }), + + values: protectedProcedure + .input(z.object({ event: z.string(), property: z.string() })) + .query(async ({ input }) => { + const events = await db.event.findMany({ + where: { + name: input.event, + properties: { + path: input.property.split(".").slice(1), + not: Prisma.DbNull, + }, + createdAt: { + // Take last 30 days + gte: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 30), + }, + }, + }); + + const values = uniq( + map(path(input.property.split(".")), events), + ) as string[]; + + return { + types: uniq( + values.map((value) => + Array.isArray(value) ? "array" : typeof value, + ), + ), + values, + }; + }), + + chart: protectedProcedure + .input( + z.object({ + startDate: z.date().nullish(), + endDate: z.date().nullish(), + chartType: zChartType, + interval: zTimeInterval, + events: zChartEvents, + breakdowns: zChartBreakdowns, + }), + ) + .query( + async ({ + input: { chartType, events, breakdowns, interval, ...input }, + }) => { + const startDate = input.startDate ?? new Date(); + const endDate = input.endDate ?? new Date(); + const series: Awaited> = []; + for (const event of events) { + series.push( + ...(await getChartData({ + chartType, + event, + breakdowns, + interval, + startDate, + endDate, + })), + ); + } + + return { + series: series.sort((a, b) => { + const sumA = a.data.reduce((acc, item) => acc + item.count, 0); + const sumB = b.data.reduce((acc, item) => acc + item.count, 0); + return sumB - sumA; + }), + }; + }, + ), +}); + +function fillEmptySpotsInTimeline( + items: ResultItem[], + interval: string, + startDate: Date, + 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 getYear = (date: Date) => date.getFullYear(); + const getMonth = (date: Date) => date.getMonth(); + const getDay = (date: Date) => date.getDate(); + const getHour = (date: Date) => date.getHours(); + const getMinute = (date: Date) => date.getMinutes(); + + const item = items.find((item) => { + const date = new Date(item.date); + + if (interval === "month") { + return ( + getYear(date) === getYear(currentDate) && + getMonth(date) === getMonth(currentDate) + ); + } + if (interval === "day") { + return ( + getYear(date) === getYear(currentDate) && + getMonth(date) === getMonth(currentDate) && + getDay(date) === getDay(currentDate) + ); + } + if (interval === "hour") { + return ( + getYear(date) === getYear(currentDate) && + getMonth(date) === getMonth(currentDate) && + getDay(date) === getDay(currentDate) && + getHour(date) === getHour(currentDate) + ); + } + if (interval === "minute") { + return ( + getYear(date) === getYear(currentDate) && + getMonth(date) === getMonth(currentDate) && + getDay(date) === getDay(currentDate) && + getHour(date) === getHour(currentDate) && + getMinute(date) === getMinute(currentDate) + ); + } + }); + + if (item) { + result.push(item); + } else { + result.push({ + date: currentDate.toISOString(), + count: 0, + label: null, + }); + } + + switch (interval) { + case "day": { + currentDate.setDate(currentDate.getDate() + 1); + break; + } + case "hour": { + currentDate.setHours(currentDate.getHours() + 1); + break; + } + case "minute": { + currentDate.setMinutes(currentDate.getMinutes() + 1); + break; + } + case "month": { + currentDate.setMonth(currentDate.getMonth() + 1); + break; + } + } + } + + return sort(function (a, b) { + return new Date(a.date).getTime() - new Date(b.date).getTime(); + }, result); +} diff --git a/apps/web/src/server/api/routers/example.ts b/apps/web/src/server/api/routers/example.ts index 8d0d4637..b07caa76 100644 --- a/apps/web/src/server/api/routers/example.ts +++ b/apps/web/src/server/api/routers/example.ts @@ -15,8 +15,8 @@ export const exampleRouter = createTRPCRouter({ }; }), - getAll: publicProcedure.query(({ ctx }) => { - return ctx.db.example.findMany(); + getAll: publicProcedure.query(() => { + return [] }), getSecretMessage: protectedProcedure.query(() => { diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 73eef929..f8f3bf94 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -1,5 +1,4 @@ -import { PrismaAdapter } from "@next-auth/prisma-adapter"; -import { NextApiRequest, type GetServerSidePropsContext } from "next"; +import { type NextApiRequest, type GetServerSidePropsContext } from "next"; import { getServerSession, type DefaultSession, @@ -39,7 +38,7 @@ declare module "next-auth" { */ export const authOptions: NextAuthOptions = { callbacks: { - session: ({ session, user, token }) => ({ + session: ({ session, token }) => ({ ...session, user: { ...session.user, diff --git a/apps/web/src/server/exceptions.ts b/apps/web/src/server/exceptions.ts index 62e39ae0..f622d43c 100644 --- a/apps/web/src/server/exceptions.ts +++ b/apps/web/src/server/exceptions.ts @@ -1,8 +1,8 @@ import { - MixanIssue, - MixanErrorResponse + type MixanIssue, + type MixanErrorResponse } from '@mixan/types' -import { NextApiResponse } from 'next' +import { type NextApiResponse } from 'next' export class HttpError extends Error { public status: number @@ -13,7 +13,7 @@ export class HttpError extends Error { super(message instanceof Error ? message.message : message) this.status = status this.message = message instanceof Error ? message.message : message - this.issues = issues || [] + this.issues = issues ?? [] } toJson(): MixanErrorResponse { @@ -31,7 +31,7 @@ export function createIssues(arr: Array) { throw new HttpError(400, 'Issues', arr) } -export function createError(status = 500, error: unknown | Error | string) { +export function createError(status = 500, error: unknown) { if(error instanceof Error || typeof error === 'string') { return new HttpError(status, error) } @@ -39,7 +39,7 @@ export function createError(status = 500, error: unknown | Error | string) { return new HttpError(500, 'Unexpected error occured') } -export function handleError(res: NextApiResponse, error: Error | HttpError | unknown) { +export function handleError(res: NextApiResponse, error: unknown) { if(error instanceof HttpError) { return res.status(error.status).json(error.toJson()) } diff --git a/apps/web/src/services/profile.service.ts b/apps/web/src/services/profile.service.ts index 637fc97f..73046980 100644 --- a/apps/web/src/services/profile.service.ts +++ b/apps/web/src/services/profile.service.ts @@ -25,7 +25,7 @@ export async function tickProfileProperty({ } const properties = ( - typeof profile.properties === 'object' ? profile.properties || {} : {} + typeof profile.properties === 'object' ? profile.properties ?? {} : {} ) as Record const value = name in properties ? properties[name] : 0 @@ -34,6 +34,7 @@ export async function tickProfileProperty({ } if (typeof tick !== 'number') { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions throw new HttpError(400, `Value is not a number ${tick} (${typeof tick})`) } diff --git a/apps/web/src/styles/globals.css b/apps/web/src/styles/globals.css index b5c61c95..6a757250 100644 --- a/apps/web/src/styles/globals.css +++ b/apps/web/src/styles/globals.css @@ -1,3 +1,76 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts new file mode 100644 index 00000000..882187dd --- /dev/null +++ b/apps/web/src/types/index.ts @@ -0,0 +1,13 @@ +import { type zTimeInterval, type zChartBreakdown, type zChartEvent } from "@/utils/validation"; +import { type TooltipProps } from "recharts"; +import { type z } from "zod"; + +export type IChartEvent = z.infer +export type IChartEventFilter = IChartEvent['filters'][number] +export type IChartBreakdown = z.infer +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/cn.ts b/apps/web/src/utils/cn.ts new file mode 100644 index 00000000..a7dc3a13 --- /dev/null +++ b/apps/web/src/utils/cn.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} \ No newline at end of file diff --git a/apps/web/src/utils/date.ts b/apps/web/src/utils/date.ts new file mode 100644 index 00000000..e69de29b diff --git a/apps/web/src/utils/object.ts b/apps/web/src/utils/object.ts new file mode 100644 index 00000000..e90efb75 --- /dev/null +++ b/apps/web/src/utils/object.ts @@ -0,0 +1,16 @@ + +export function toDots(obj: Record, path = ''): Record { + return Object.entries(obj).reduce((acc, [key, value]) => { + if(typeof value === 'object' && value !== null) { + return { + ...acc, + ...toDots(value as Record, `${path}${key}.`) + } + } + + return { + ...acc, + [`${path}${key}`]: value, + } + }, {}) +} \ No newline at end of file diff --git a/apps/web/src/utils/theme.ts b/apps/web/src/utils/theme.ts new file mode 100644 index 00000000..9e3b3b28 --- /dev/null +++ b/apps/web/src/utils/theme.ts @@ -0,0 +1,13 @@ +import resolveConfig from "tailwindcss/resolveConfig"; +import tailwinConfig from "../../tailwind.config.js"; +const config = resolveConfig(tailwinConfig); + +export const theme = config.theme as any; + +export function getChartColor(index: number): string { + const chartColors: string[] = Object.keys(theme?.colors ?? {}) + .filter((key) => key.startsWith("chart-")) + .map((key) => theme.colors[key] as string); + + return chartColors[index % chartColors.length]!; +} diff --git a/apps/web/src/utils/validation.ts b/apps/web/src/utils/validation.ts new file mode 100644 index 00000000..cb14885e --- /dev/null +++ b/apps/web/src/utils/validation.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const zChartEvent = z.object({ + id: z.string(), + name: z.string(), + displayName: z.string(), + filters: z.array( + z.object({ + id: z.string(), + name: z.string(), + value: z.string(), + }), + ), +}); +export const zChartBreakdown = z.object({ + id: z.string(), + name: z.string(), +}); + +export const zChartEvents = z.array(zChartEvent); +export const zChartBreakdowns = z.array(zChartBreakdown); + +export const zChartType = z.enum(["bar", "linear"]); + +export const zTimeInterval = z.enum(["day", "hour", "month"]); diff --git a/apps/web/tailwind.config.js b/apps/web/tailwind.config.js new file mode 100644 index 00000000..477dd324 --- /dev/null +++ b/apps/web/tailwind.config.js @@ -0,0 +1,100 @@ +const colors = [ + "#7856ff", + "#ff7557", + "#7fe1d8", + "#f8bc3c", + "#b3596e", + "#72bef4", + "#ffb27a", + "#0f7ea0", + "#3ba974", + "#febbb2", + "#cb80dc", + "#5cb7af", +]; + +/** @type {import('tailwindcss').Config} */ +module.exports = { + safelist: [ + ...colors.map((color) => `chart-${color}`), + ], + darkMode: ["class"], + content: [ + "./pages/**/*.{ts,tsx}", + "./components/**/*.{ts,tsx}", + "./app/**/*.{ts,tsx}", + "./src/**/*.{ts,tsx}", + ], + theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + ...colors.reduce((acc, color, index) => { + return { + ...acc, + [`chart-${index}`]: color, + }; + }, {}), + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + keyframes: { + "accordion-down": { + from: { height: 0 }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: 0 }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", + }, + }, + }, + plugins: [require("tailwindcss-animate")], +}; diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts index d4d3fa29..cc45ec94 100644 --- a/apps/web/tailwind.config.ts +++ b/apps/web/tailwind.config.ts @@ -3,7 +3,26 @@ import { type Config } from "tailwindcss"; export default { content: ["./src/**/*.{js,ts,jsx,tsx}"], theme: { - extend: {}, + extend: { + colors: { + 'chart-0': '', + 'chart-1': '', + 'chart-2': '', + 'chart-3': '', + 'chart-4': '', + 'chart-5': '', + 'chart-6': '', + 'chart-7': '', + 'chart-8': '', + 'chart-9': '', + 'chart-10': '', + 'chart-11': '', + 'chart-12': '', + 'chart-13': '', + 'chart-14': '', + 'chart-15': '', + } + }, }, plugins: [], } satisfies Config; diff --git a/bun.lockb b/bun.lockb index 17fe77a8..54c4f369 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/packages/types/index.ts b/packages/types/index.ts index 43fb84f2..3aa2ab48 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -63,7 +63,8 @@ export type MixanErrorResponse = { status: 'error' code: number message: string - issues: Array + issues?: Array + stack?: string } export type MixanResponse = {