gui: work in progress

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-10-17 21:47:37 +02:00
parent b9fe6127ff
commit 206ae54dea
53 changed files with 2632 additions and 88 deletions

View File

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

16
apps/web/components.json Normal file
View File

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

View File

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

View File

@@ -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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline">Open</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<User className="mr-2 h-4 w-4" />
<span>Profile</span>
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<CreditCard className="mr-2 h-4 w-4" />
<span>Billing</span>
<DropdownMenuShortcut>B</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
<DropdownMenuShortcut>S</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<Keyboard className="mr-2 h-4 w-4" />
<span>Keyboard shortcuts</span>
<DropdownMenuShortcut>K</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<Users className="mr-2 h-4 w-4" />
<span>Team</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<UserPlus className="mr-2 h-4 w-4" />
<span>Invite users</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem>
<Mail className="mr-2 h-4 w-4" />
<span>Email</span>
</DropdownMenuItem>
<DropdownMenuItem>
<MessageSquare className="mr-2 h-4 w-4" />
<span>Message</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<PlusCircle className="mr-2 h-4 w-4" />
<span>More...</span>
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuItem>
<Plus className="mr-2 h-4 w-4" />
<span>New Team</span>
<DropdownMenuShortcut>+T</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Github className="mr-2 h-4 w-4" />
<span>GitHub</span>
</DropdownMenuItem>
<DropdownMenuItem>
<LifeBuoy className="mr-2 h-4 w-4" />
<span>Support</span>
</DropdownMenuItem>
<DropdownMenuItem disabled>
<Cloud className="mr-2 h-4 w-4" />
<span>API</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,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 && (
<>
<LineChart width={800} height={400}>
<Legend />
<YAxis dataKey={"count"}></YAxis>
<Tooltip content={<ReportLineChartTooltip />} />
{/* <Tooltip /> */}
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={(m: Date) => {
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 (
<Line
type="monotone"
key={key}
isAnimationActive={false}
strokeWidth={2}
dataKey="count"
stroke={strokeColor}
data={serie.data}
name={serie.name}
/>
);
})}
</LineChart>
<ReportTable data={chart.data} />
</>
)}
</>
);
}

View File

@@ -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 (
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl">
{visible.map((item) => {
return (
<div key={item.payload.label} className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: item.color }}
></div>
<div className="flex flex-col">
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{item.payload.label}
</div>
<div>{item.payload.count}</div>
</div>
</div>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground">and {hidden.length} more...</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="flex min-w-0">
{/* Labels */}
<div>
<div className="font-medium">Name</div>
{data.series.map((serie, index) => {
const checked = index < 5;
return (
<div
key={serie.name}
className="max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap flex items-center gap-2"
>
<Checkbox
style={checked ? {
background: getChartColor(index),
borderColor: getChartColor(index),
} : undefined}
checked={checked}
/>
{serie.name}
</div>
);
})}
</div>
{/* ScrollView for all values */}
<div className="min-w-0 overflow-auto">
{/* Header */}
<div className="flex">
{data.series[0]?.data.map((serie, index) => (
<div
key={serie.date.toString()}
className="min-w-[80px] text-right font-medium"
>
{formatDate(serie.date)}
</div>
))}
</div>
{/* Values */}
{data.series.map((serie, index) => {
return (
<div className="flex" key={serie.name}>
{serie.data.map((item) => {
return (
<div key={item.date} className="min-w-[80px] text-right">
{item.count}
</div>
);
})}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -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<Omit<IChartEvent, "id">>) => {
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<IChartEvent>) => {
state.events = state.events.map((event) => {
if (event.id === action.payload.id) {
return action.payload;
}
return event;
});
},
// Breakdowns
addBreakdown: (
state,
action: PayloadAction<Omit<IChartBreakdown, "id">>,
) => {
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<IChartBreakdown>) => {
state.breakdowns = state.breakdowns.map((breakdown) => {
if (breakdown.id === action.payload.id) {
return action.payload;
}
return breakdown;
});
},
// Interval
changeInterval: (state, action: PayloadAction<IInterval>) => {
state.interval = action.payload;
},
// Date range
changeStartDate: (state, action: PayloadAction<Date>) => {
state.startDate = action.payload;
},
// Date range
changeEndDate: (state, action: PayloadAction<Date>) => {
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;

View File

@@ -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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem className="text-red-600" onClick={() => onClick('remove')}>
<Trash className="mr-2 h-4 w-4" />
Delete
<DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -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 (
<div>
<h3 className="mb-2 font-medium">Breakdown</h3>
<div className="flex flex-col gap-4">
{selectedBreakdowns.map((item, index) => {
return (
<div key={item.name} className="rounded-lg border">
<div className="flex items-center gap-2 p-2 px-4">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-purple-500 text-xs font-medium text-white">
{index}
</div>
<Combobox
value={item.name}
onChange={(value) => {
dispatch(
changeBreakdown({
...item,
name: value,
}),
);
}}
items={propertiesCombobox}
placeholder="Select..."
/>
<ReportBreakdownMore onClick={handleMore(item)} />
</div>
</div>
);
})}
{selectedBreakdowns.length === 0 && (
<Combobox
value={""}
onChange={(value) => {
dispatch(
addBreakdown({
name: value,
}),
);
}}
items={propertiesCombobox}
placeholder="Select breakdown"
/>
)}
</div>
</div>
);
}

View File

@@ -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<boolean>;
};
export function ReportEventFilters({
event,
isCreating,
setIsCreating,
}: ReportEventFiltersProps) {
const dispatch = useDispatch();
const propertiesQuery = api.chartMeta.properties.useQuery(
{
event: event.name,
},
{
enabled: !!event.name,
},
);
return (
<div>
<div className="flex flex-col divide-y bg-slate-50">
{event.filters.map((filter) => {
return <Filter key={filter.name} filter={filter} event={event} />;
})}
<CommandDialog open={isCreating} onOpenChange={setIsCreating} modal>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>Such emptyness 🤨</CommandEmpty>
<CommandGroup heading="Properties">
{propertiesQuery.data?.map((item) => (
<CommandItem
key={item}
onSelect={() => {
setIsCreating(false);
dispatch(
changeEvent({
...event,
filters: [
...event.filters,
{
id: (event.filters.length + 1).toString(),
name: item,
value: "",
},
],
}),
);
}}
>
<CreditCard className="mr-2 h-4 w-4" />
<RenderDots className="text-sm">{item}</RenderDots>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</CommandList>
</CommandDialog>
</div>
</div>
);
}
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 (
<div
key={filter.name}
className="px-4 py-2 shadow-[inset_6px_0_0] shadow-slate-200 first:border-t"
>
<div className="mb-2 flex items-center gap-2">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-emerald-600 text-xs font-medium text-white">
<SlidersHorizontal size={10} />
</div>
<RenderDots className="text-sm">{filter.name}</RenderDots>
<Button variant="ghost" size="sm" onClick={removeFilter}>
<Trash size={16} />
</Button>
</div>
{/* <ComboboxMulti items={valuesCombobox} selected={[]} setSelected={(fn) => {
return fn(filter.value)
//
}} /> */}
<Combobox
items={valuesCombobox}
value={filter.value}
placeholder="Select value"
onChange={changeFilter}
/>
{/* <Input
value={filter.value}
onChange={(e) => {
dispatch(
changeEvent({
...event,
filters: event.filters.map((item) => {
if (item.id === filter.id) {
return {
...item,
value: e.currentTarget.value,
};
}
return item;
}),
}),
);
}}
/> */}
</div>
);
}

View File

@@ -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 (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onClick('createFilter')}>
<Filter className="mr-2 h-4 w-4" />
Add filter
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600" onClick={() => onClick('remove')}>
<Trash className="mr-2 h-4 w-4" />
Delete
<DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -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 (
<div>
<h3 className="mb-2 font-medium">Events</h3>
<div className="flex flex-col gap-4">
{selectedEvents.map((event, index) => {
return (
<div key={event.name} className="border rounded-lg">
<div className="flex gap-2 items-center p-2 px-4">
<div className="flex-shrink-0 bg-purple-500 w-5 h-5 rounded text-xs flex items-center justify-center text-white font-medium">{index}</div>
<Combobox
value={event.name}
onChange={(value) => {
dispatch(
changeEvent({
...event,
name: value,
filters: [],
}),
);
}}
items={eventsCombobox}
placeholder="Select event"
/>
<ReportEventMore onClick={handleMore(event)} />
</div>
<ReportEventFilters
{...{ isCreating, setIsCreating, event }}
/>
</div>
);
})}
<Combobox
value={""}
onChange={(value) => {
dispatch(
addEvent({
displayName: `${value} (${selectedEvents.length})`,
name: value,
filters: [],
}),
);
}}
items={eventsCombobox}
placeholder="Select event"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { ReportEvents } from "./ReportEvents";
import { ReportBreakdowns } from "./ReportBreakdowns";
export function ReportSidebar() {
return (
<div className="flex flex-col gap-4 p-4">
<ReportEvents />
<ReportBreakdowns />
</div>
);
}

View File

@@ -0,0 +1,21 @@
import { cn } from "@/utils/cn";
import { ChevronRight } from "lucide-react";
interface RenderDotsProps extends React.HTMLAttributes<HTMLDivElement> {
children: string;
}
export function RenderDots({ children, className, ...props }: RenderDotsProps) {
return (
<div {...props} className={cn('flex gap-1', className)}>
{children.split(".").map((str, index) => {
return (
<div className="flex items-center gap-1" key={str + index}>
{index !== 0 && <ChevronRight className="flex-shrink-0 !w-3 !h-3 top-[0.5px] relative" />}
<span>{str}</span>
</div>
);
})}
</div>
);
}

View File

@@ -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<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -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<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -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<typeof CheckboxPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof CheckboxPrimitive.Root>
>(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }

View File

@@ -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<React.SetStateAction<Framework[]>>;
items: Framework[];
}
export function ComboboxMulti({ items, selected, setSelected, ...props }: ComboboxMultiProps) {
const inputRef = React.useRef<HTMLInputElement>(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<HTMLDivElement>) => {
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 <input /> field
if (e.key === "Escape") {
input.blur();
}
}
}, []);
const selectables = items.filter(framework => !selected.includes(framework));
return (
<Command onKeyDown={handleKeyDown} className="overflow-visible bg-white">
<div
className="group border border-input px-3 py-2 text-sm ring-offset-background rounded-md focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2"
>
<div className="flex gap-1 flex-wrap">
{selected.map((framework) => {
return (
<Badge key={framework.value} variant="secondary">
{framework.label}
<button
className="ml-1 ring-offset-background rounded-full outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
onKeyDown={(e) => {
if (e.key === "Enter") {
handleUnselect(framework);
}
}}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onClick={() => handleUnselect(framework)}
>
<X className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
</Badge>
)
})}
{/* Avoid having the "Search" Icon */}
<CommandPrimitive.Input
ref={inputRef}
value={inputValue}
onValueChange={setInputValue}
onBlur={() => setOpen(false)}
onFocus={() => setOpen(true)}
placeholder="Select frameworks..."
className="ml-2 bg-transparent outline-none placeholder:text-muted-foreground flex-1"
/>
</div>
</div>
<div className="relative mt-2">
{open && selectables.length > 0 ?
<div className="absolute w-full z-10 top-0 rounded-md border bg-popover text-popover-foreground shadow-md outline-none animate-in">
<CommandGroup className="h-full overflow-auto">
{selectables.map((framework) => {
return (
<CommandItem
key={framework.value}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
}}
onSelect={(value) => {
setInputValue("")
setSelected(prev => [...prev, framework])
}}
className={"cursor-pointer"}
>
{framework.label}
</CommandItem>
);
})}
</CommandGroup>
</div>
: null}
</div>
</Command >
)
}

View File

@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full min-w-0 justify-between"
>
{value ? find(value)?.label ?? "No match" : placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full min-w-0 p-0">
<Command>
<CommandInput placeholder="Search item..." />
<CommandEmpty>Nothing selected</CommandEmpty>
<CommandGroup className="max-h-[200px] overflow-auto">
{items.map((item) => (
<CommandItem
key={item.value}
onSelect={(currentValue) => {
const value = find(currentValue)?.value ?? "";
onChange(value);
setOpen(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4 flex-shrink-0",
value === item.value ? "opacity-100" : "opacity-0",
)}
/>
{item.label}
</CommandItem>
))}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -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<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
type CommandDialogProps = DialogProps
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
"flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
"overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn("-mx-1 h-px bg-border", className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = "CommandShortcut"
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg md:w-full",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -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<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/utils/cn"
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -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<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

View File

@@ -0,0 +1,32 @@
import * as React from "react"
import { cn } from "@/utils/cn"
export type RadioGroupProps = React.InputHTMLAttributes<HTMLDivElement>
export type RadioGroupItemProps = React.InputHTMLAttributes<HTMLButtonElement>
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
({ className, type, ...props }, ref) => {
return (
<div
className={cn(
"flex h-10 w-full divide-x rounded-md border border-input bg-background text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
const RadioGroupItem = React.forwardRef<HTMLButtonElement, RadioGroupItemProps>(({className, ...props}, ref) => {
return (
<button {...props} className={cn('flex-1 hover:bg-slate-100 transition-colors font-medium', className)} type="button" ref={ref} />
)
})
RadioGroup.displayName = "RadioGroup"
RadioGroupItem.displayName = "RadioGroupItem"
export { RadioGroup, RadioGroupItem }

View File

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

View File

@@ -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 (
<SessionProvider session={session}>
<Component {...pageProps} />
<ReduxProvider store={store}>
<Component {...pageProps} />
</ReduxProvider>
</SessionProvider>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,7 +33,7 @@ export default async function handler(
last_name: null,
avatar: null,
properties: {
...(properties || {}),
...(properties ?? {}),
},
project_id: projectId,
},

View File

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

View File

@@ -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() {
<meta name="description" content="Generated by create-t3-app" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className=" flex min-h-screen flex-col items-center justify-center bg-gradient-to-b from-[#2e026d] to-[#15162c]">
<div className="container flex flex-col items-center justify-center gap-12 px-4 py-16 ">
<h1 className="text-5xl font-extrabold tracking-tight text-white sm:text-[5rem]">
Create <span className="text-[hsl(280,100%,70%)]">T3</span> App
</h1>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:gap-8">
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/usage/first-steps"
target="_blank"
>
<h3 className="text-2xl font-bold">First Steps </h3>
<div className="text-lg">
Just the basics - Everything you need to know to set up your
database and authentication.
</div>
</Link>
<Link
className="flex max-w-xs flex-col gap-4 rounded-xl bg-white/10 p-4 text-white hover:bg-white/20"
href="https://create.t3.gg/en/introduction"
target="_blank"
>
<h3 className="text-2xl font-bold">Documentation </h3>
<div className="text-lg">
Learn more about Create T3 App, the libraries it uses, and how
to deploy it.
</div>
</Link>
</div>
<div className="flex flex-col items-center gap-2">
<p className="text-2xl text-white">
{hello.data ? hello.data.greeting : "Loading tRPC query..."}
</p>
<AuthShowcase />
<main className="grid min-h-screen grid-cols-[400px_minmax(0,1fr)] divide-x">
<div>
<ReportSidebar />
</div>
<div className="flex flex-col gap-4 p-4">
<div className="flex gap-4">
<RadioGroup>
<RadioGroupItem>7 days</RadioGroupItem>
<RadioGroupItem>14 days</RadioGroupItem>
<RadioGroupItem>1 month</RadioGroupItem>
<RadioGroupItem>3 month</RadioGroupItem>
<RadioGroupItem>6 month</RadioGroupItem>
<RadioGroupItem>1 year</RadioGroupItem>
</RadioGroup>
<div className="w-full max-w-[200px]">
<Combobox
placeholder="Interval"
onChange={(value) => {
dispatch(changeInterval(value as IInterval));
}}
value={interval}
items={[
{
label: "Hour",
value: "hour",
},
{
label: "Day",
value: "day",
},
{
label: "Month",
value: "month",
},
]}
></Combobox>
</div>
</div>
{startDate && endDate && (
<ReportLineChart
startDate={startDate}
endDate={endDate}
events={events}
breakdowns={breakdowns}
interval={interval}
/>
)}
</div>
</main>
</>
);
}
function AuthShowcase() {
const { data: sessionData } = useSession();
const { data: secretMessage } = api.example.getSecretMessage.useQuery(
undefined, // no input
{ enabled: sessionData?.user !== undefined }
);
return (
<div className="flex flex-col items-center justify-center gap-4">
<p className="text-center text-2xl text-white">
{sessionData && <span>Logged in as {sessionData.user?.name}</span>}
{secretMessage && <span> - {secretMessage}</span>}
</p>
<button
className="rounded-full bg-white/10 px-10 py-3 font-semibold text-white no-underline transition hover:bg-white/20"
onClick={sessionData ? () => void signOut() : () => void signIn()}
>
{sessionData ? "Sign out" : "Sign in"}
</button>
</div>
);
}

View File

@@ -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<typeof store.getState>
export type AppDispatch = typeof store.dispatch
export const useDispatch: () => AppDispatch = useBaseDispatch
export const useSelector: TypedUseSelectorHook<RootState> = useBaseSelector
export default store

View File

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

View File

@@ -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<ResultItem[]>(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<string, ResultItem[]>,
);
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<string, unknown>;
const dotNotation = toDots(properties);
return [...acc, ...Object.keys(dotNotation)];
}, [] as string[]);
return pipe(
sort<string>((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<ReturnType<typeof getChartData>> = [];
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);
}

View File

@@ -15,8 +15,8 @@ export const exampleRouter = createTRPCRouter({
};
}),
getAll: publicProcedure.query(({ ctx }) => {
return ctx.db.example.findMany();
getAll: publicProcedure.query(() => {
return []
}),
getSecretMessage: protectedProcedure.query(() => {

View File

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

View File

@@ -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<MixanIssue>) {
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())
}

View File

@@ -25,7 +25,7 @@ export async function tickProfileProperty({
}
const properties = (
typeof profile.properties === 'object' ? profile.properties || {} : {}
typeof profile.properties === 'object' ? profile.properties ?? {} : {}
) as Record<string, number>
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})`)
}

View File

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

View File

@@ -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<typeof zChartEvent>
export type IChartEventFilter = IChartEvent['filters'][number]
export type IChartBreakdown = z.infer<typeof zChartBreakdown>
export type IInterval = z.infer<typeof zTimeInterval>
export type IToolTipProps<T> = Omit<TooltipProps<number, string>, 'payload'> & {
payload?: Array<T>
}

6
apps/web/src/utils/cn.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

View File

@@ -0,0 +1,16 @@
export function toDots(obj: Record<string, unknown>, path = ''): Record<string, number | string | boolean> {
return Object.entries(obj).reduce((acc, [key, value]) => {
if(typeof value === 'object' && value !== null) {
return {
...acc,
...toDots(value as Record<string, unknown>, `${path}${key}.`)
}
}
return {
...acc,
[`${path}${key}`]: value,
}
}, {})
}

View File

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

View File

@@ -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"]);

100
apps/web/tailwind.config.js Normal file
View File

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

View File

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

BIN
bun.lockb

Binary file not shown.

View File

@@ -63,7 +63,8 @@ export type MixanErrorResponse = {
status: 'error'
code: number
message: string
issues: Array<MixanIssue>
issues?: Array<MixanIssue>
stack?: string
}
export type MixanResponse<T> = {