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 (
+
+ )
+}
+
+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 (
+
+ )
+})
+
+RadioGroup.displayName = "RadioGroup"
+RadioGroupItem.displayName = "RadioGroupItem"
+
+export { RadioGroup, RadioGroupItem }
diff --git a/apps/web/src/hooks/useFormatDateInterval.ts b/apps/web/src/hooks/useFormatDateInterval.ts
new file mode 100644
index 00000000..07729e0c
--- /dev/null
+++ b/apps/web/src/hooks/useFormatDateInterval.ts
@@ -0,0 +1,27 @@
+import { type IInterval } from "@/types";
+
+
+export function formatDateInterval(interval: IInterval, date: Date): string {
+ if (interval === "hour") {
+ return new Intl.DateTimeFormat("en-GB", {
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(date);
+ }
+
+ if (interval === "month") {
+ return new Intl.DateTimeFormat("en-GB", { month: "short" }).format(date);
+ }
+
+ if (interval === "day") {
+ return new Intl.DateTimeFormat("en-GB", { weekday: "short" }).format(
+ date,
+ );
+ }
+
+ return date.toISOString();
+}
+
+export function useFormatDateInterval(interval: IInterval) {
+ return (date: Date | string) => formatDateInterval(interval, typeof date === "string" ? new Date(date) : date);
+}
\ No newline at end of file
diff --git a/apps/web/src/pages/_app.tsx b/apps/web/src/pages/_app.tsx
index 2843ab6e..c9b49dd4 100644
--- a/apps/web/src/pages/_app.tsx
+++ b/apps/web/src/pages/_app.tsx
@@ -1,6 +1,8 @@
import { type Session } from "next-auth";
import { SessionProvider } from "next-auth/react";
import { type AppType } from "next/app";
+import store from "@/redux";
+import { Provider as ReduxProvider } from "react-redux";
import { api } from "@/utils/api";
@@ -12,7 +14,9 @@ const MyApp: AppType<{ session: Session | null }> = ({
}) => {
return (
-
+
+
+
);
};
diff --git a/apps/web/src/pages/api/sdk/events.ts b/apps/web/src/pages/api/sdk/events.ts
index b813ca1d..2889fccb 100644
--- a/apps/web/src/pages/api/sdk/events.ts
+++ b/apps/web/src/pages/api/sdk/events.ts
@@ -1,7 +1,7 @@
import { validateSdkRequest } from '@/server/auth'
import { db } from '@/server/db'
import { createError, handleError } from '@/server/exceptions'
-import { EventPayload } from '@mixan/types'
+import { type EventPayload } from '@mixan/types'
import type { NextApiRequest, NextApiResponse } from 'next'
interface Request extends NextApiRequest {
diff --git a/apps/web/src/pages/api/sdk/profiles/[profileId]/decrement.ts b/apps/web/src/pages/api/sdk/profiles/[profileId]/decrement.ts
index ffea7826..7ebad717 100644
--- a/apps/web/src/pages/api/sdk/profiles/[profileId]/decrement.ts
+++ b/apps/web/src/pages/api/sdk/profiles/[profileId]/decrement.ts
@@ -1,8 +1,7 @@
import { validateSdkRequest } from "@/server/auth";
-import { db } from "@/server/db";
import { createError, handleError } from "@/server/exceptions";
import { tickProfileProperty } from "@/services/profile.service";
-import { ProfileIncrementPayload, ProfilePayload } from "@mixan/types";
+import { type ProfileIncrementPayload } from "@mixan/types";
import type { NextApiRequest, NextApiResponse } from "next";
interface Request extends NextApiRequest {
diff --git a/apps/web/src/pages/api/sdk/profiles/[profileId]/increment.ts b/apps/web/src/pages/api/sdk/profiles/[profileId]/increment.ts
index a234dca3..e48036a4 100644
--- a/apps/web/src/pages/api/sdk/profiles/[profileId]/increment.ts
+++ b/apps/web/src/pages/api/sdk/profiles/[profileId]/increment.ts
@@ -1,8 +1,7 @@
import { validateSdkRequest } from "@/server/auth";
-import { db } from "@/server/db";
import { createError, handleError } from "@/server/exceptions";
import { tickProfileProperty } from "@/services/profile.service";
-import { ProfileIncrementPayload, ProfilePayload } from "@mixan/types";
+import { type ProfileIncrementPayload } from "@mixan/types";
import type { NextApiRequest, NextApiResponse } from "next";
interface Request extends NextApiRequest {
diff --git a/apps/web/src/pages/api/sdk/profiles/[profileId]/index.ts b/apps/web/src/pages/api/sdk/profiles/[profileId]/index.ts
index f243d021..778e89dd 100644
--- a/apps/web/src/pages/api/sdk/profiles/[profileId]/index.ts
+++ b/apps/web/src/pages/api/sdk/profiles/[profileId]/index.ts
@@ -2,7 +2,7 @@ import { validateSdkRequest } from "@/server/auth";
import { db } from "@/server/db";
import { createError, handleError } from "@/server/exceptions";
import { getProfile } from "@/services/profile.service";
-import { ProfilePayload } from "@mixan/types";
+import { type ProfilePayload } from "@mixan/types";
import type { NextApiRequest, NextApiResponse } from "next";
interface Request extends NextApiRequest {
@@ -34,9 +34,9 @@ export default async function handler(req: Request, res: NextApiResponse) {
avatar: body.avatar,
properties: {
...(typeof profile.properties === "object"
- ? profile.properties || {}
+ ? profile.properties ?? {}
: {}),
- ...(body.properties || {}),
+ ...(body.properties ?? {}),
},
},
});
diff --git a/apps/web/src/pages/api/sdk/profiles/index.ts b/apps/web/src/pages/api/sdk/profiles/index.ts
index f70c8ee6..55213bc0 100644
--- a/apps/web/src/pages/api/sdk/profiles/index.ts
+++ b/apps/web/src/pages/api/sdk/profiles/index.ts
@@ -33,7 +33,7 @@ export default async function handler(
last_name: null,
avatar: null,
properties: {
- ...(properties || {}),
+ ...(properties ?? {}),
},
project_id: projectId,
},
diff --git a/apps/web/src/pages/api/setup.ts b/apps/web/src/pages/api/setup.ts
index d9624933..f28da1a8 100644
--- a/apps/web/src/pages/api/setup.ts
+++ b/apps/web/src/pages/api/setup.ts
@@ -2,9 +2,9 @@ import { db } from "@/server/db";
import { handleError } from "@/server/exceptions";
import { hashPassword } from "@/services/hash.service";
import { randomUUID } from "crypto";
-import { NextApiRequest, NextApiResponse } from "next";
+import { type NextApiRequest, type NextApiResponse } from "next";
-export default async function (req: NextApiRequest, res: NextApiResponse) {
+export default async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const counts = await db.$transaction([
db.organization.count(),
diff --git a/apps/web/src/pages/index.tsx b/apps/web/src/pages/index.tsx
index 365788da..4d577cff 100644
--- a/apps/web/src/pages/index.tsx
+++ b/apps/web/src/pages/index.tsx
@@ -1,11 +1,21 @@
-import { signIn, signOut, useSession } from "next-auth/react";
import Head from "next/head";
-import Link from "next/link";
-import { api } from "@/utils/api";
+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 { changeInterval } from "@/components/report/reportSlice";
+import { type IInterval } from "@/types";
export default function Home() {
- const hello = api.example.hello.useQuery({ text: "from tRPC" });
+ 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);
return (
<>
@@ -14,67 +24,56 @@ export default function Home() {
-
-
-
- Create T3 App
-
-
-
-
First Steps →
-
- Just the basics - Everything you need to know to set up your
- database and authentication.
-
-
-
-
Documentation →
-
- Learn more about Create T3 App, the libraries it uses, and how
- to deploy it.
-
-
-
-
-
- {hello.data ? hello.data.greeting : "Loading tRPC query..."}
-
-
+
+
+
+
+
+
+
+
+ 7 days
+ 14 days
+ 1 month
+ 3 month
+ 6 month
+ 1 year
+
+
+ {
+ dispatch(changeInterval(value as IInterval));
+ }}
+ value={interval}
+ items={[
+ {
+ label: "Hour",
+ value: "hour",
+ },
+ {
+ label: "Day",
+ value: "day",
+ },
+ {
+ label: "Month",
+ value: "month",
+ },
+ ]}
+ >
+
+ {startDate && endDate && (
+
+ )}
>
);
}
-
-function AuthShowcase() {
- const { data: sessionData } = useSession();
-
- const { data: secretMessage } = api.example.getSecretMessage.useQuery(
- undefined, // no input
- { enabled: sessionData?.user !== undefined }
- );
-
- return (
-
-
- {sessionData && Logged in as {sessionData.user?.name}}
- {secretMessage && - {secretMessage}}
-
-
-
- );
-}
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 = {