responsive design and bug fixes

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-11-04 10:01:22 +01:00
parent 13618d1fd4
commit f5670253bc
51 changed files with 992 additions and 336 deletions

View File

@@ -47,6 +47,7 @@ export function EventsTable({ data, pagination }: EventsTableProps) {
const profile = info.getValue();
return (
<Link
shallow
href={`/${params.organization}/${params.project}/profiles/${profile?.id}`}
className="flex items-center gap-2"
>
@@ -91,7 +92,7 @@ export function EventsTable({ data, pagination }: EventsTableProps) {
footer: () => 'Created At',
}),
];
}, []);
}, [params]);
return (
<>

View File

@@ -1,8 +1,11 @@
import { useState } from 'react';
import { cn } from '@/utils/cn';
import { MenuIcon } from 'lucide-react';
import Link from 'next/link';
import { Container } from '../Container';
import { Breadcrumbs } from '../navbar/Breadcrumbs';
import { NavbarMenu } from '../navbar/NavbarMenu';
import { NavbarUserDropdown } from '../navbar/NavbarUserDropdown';
interface MainLayoutProps {
children: React.ReactNode;
@@ -10,23 +13,40 @@ interface MainLayoutProps {
}
export function MainLayout({ children, className }: MainLayoutProps) {
const [visible, setVisible] = useState(false);
return (
<>
<div className="h-2 w-full bg-gradient-to-r from-blue-900 to-purple-600"></div>
<nav className="border-b border-border">
<Container className="flex h-20 items-center justify-between ">
<Link href="/" className="text-3xl">
<Link shallow href="/" className="text-3xl">
mixan
</Link>
<div className="flex items-center gap-8">
<div
className={cn(
'flex items-center gap-8 z-50',
visible === false && 'max-sm:hidden',
visible === true &&
'max-sm:flex max-sm:flex-col max-sm:absolute max-sm:inset-0 max-sm:bg-white max-sm:justify-center max-sm:top-4 max-sm:shadow-lg'
)}
>
<NavbarMenu />
<div>
<NavbarUserDropdown />
</div>
</div>
<button
className={cn(
'px-4 sm:hidden absolute z-50 top-9 right-4 transition-all',
visible === true && 'rotate-90'
)}
onClick={() => {
setVisible((p) => !p);
}}
>
<MenuIcon />
</button>
</Container>
</nav>
<main className={className}>{children}</main>
<Breadcrumbs />
<main className={cn(className, 'mb-8')}>{children}</main>
</>
);
}

View File

@@ -33,6 +33,7 @@ export function SettingsLayout({ children, className }: SettingsLayoutProps) {
<Sidebar>
{links.map(({ href, label }) => (
<Link
shallow
key={href}
href={href}
className={cn(

View File

@@ -0,0 +1,58 @@
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { api } from '@/utils/api';
import { ChevronRight, HomeIcon } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Container } from '../Container';
export function Breadcrumbs() {
const params = useOrganizationParams();
const org = api.organization.get.useQuery(
{
slug: params.organization,
},
{
enabled: !!params.organization,
staleTime: Infinity,
}
);
const pro = api.project.get.useQuery(
{
slug: params.project,
},
{
enabled: !!params.project,
staleTime: Infinity,
}
);
return (
<div className="border-b border-border text-xs">
<Container className="flex items-center gap-2 h-8">
{org.isLoading && pro.isLoading && (
<div className="animate-pulse bg-slate-200 h-4 w-24 rounded"></div>
)}
{org.data && (
<>
<HomeIcon size={14} />
<Link shallow href={`/${org.data.slug}`}>
{org.data.name}
</Link>
</>
)}
{org.data && pro.data && (
<>
<ChevronRight size={10} />
<Link shallow href={`/${org.data.slug}/${pro.data.slug}`}>
{pro.data.name}
</Link>
</>
)}
</Container>
</div>
);
}

View File

@@ -1,4 +1,3 @@
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
@@ -17,14 +16,17 @@ export function NavbarCreate() {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm">Create</Button>
<button>Create</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link href={`/${params.organization}/reports`}>
<Link
shallow
href={`/${params.organization}/${params.project}/reports`}
>
<LineChart className="mr-2 h-4 w-4" />
<span>Create a report</span>
</Link>

View File

@@ -1,24 +1,40 @@
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { cn } from '@/utils/cn';
import Link from 'next/link';
import { NavbarCreate } from './NavbarCreate';
import { NavbarUserDropdown } from './NavbarUserDropdown';
export function NavbarMenu() {
const params = useOrganizationParams();
return (
<div className="flex gap-6 items-center">
<Link href={`/${params.organization}`}>Home</Link>
<div className={cn('flex gap-6 items-center text-sm', 'max-sm:flex-col')}>
{params.project && (
<Link href={`/${params.organization}/${params.project}/events`}>
<Link shallow href={`/${params.organization}/${params.project}`}>
Home
</Link>
)}
{params.project && (
<Link shallow href={`/${params.organization}/${params.project}/events`}>
Events
</Link>
)}
{params.project && (
<Link href={`/${params.organization}/${params.project}/profiles`}>
<Link
shallow
href={`/${params.organization}/${params.project}/profiles`}
>
Profiles
</Link>
)}
<NavbarCreate />
{params.project && (
<Link
shallow
href={`/${params.organization}/${params.project}/reports`}
>
Create report
</Link>
)}
<NavbarUserDropdown />
</div>
);
}

View File

@@ -27,25 +27,28 @@ export function NavbarUserDropdown() {
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href={`/${params.organization}/settings/organization`}>
<Link
href={`/${params.organization}/settings/organization`}
shallow
>
<User className="mr-2 h-4 w-4" />
Organization
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href={`/${params.organization}/settings/projects`}>
<Link href={`/${params.organization}/settings/projects`} shallow>
<User className="mr-2 h-4 w-4" />
Projects
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href={`/${params.organization}/settings/clients`}>
<Link href={`/${params.organization}/settings/clients`} shallow>
<User className="mr-2 h-4 w-4" />
Clients
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild className="cursor-pointer">
<Link href={`/${params.organization}/settings/profile`}>
<Link href={`/${params.organization}/settings/profile`} shallow>
<User className="mr-2 h-4 w-4" />
Profile
</Link>

View File

@@ -3,32 +3,23 @@ import type { IChartType } from '@/types';
import { chartTypes } from '@/utils/constants';
import { Combobox } from '../ui/combobox';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import {
changeChartType,
changeDateRanges,
changeInterval,
} from './reportSlice';
import { changeChartType } from './reportSlice';
export function ReportChartType() {
const dispatch = useDispatch();
const type = useSelector((state) => state.report.chartType);
return (
<>
<div className="w-full max-w-[200px]">
<Combobox
placeholder="Chart type"
onChange={(value) => {
dispatch(changeChartType(value as IChartType));
}}
value={type}
items={Object.entries(chartTypes).map(([key, value]) => ({
label: value,
value: key,
}))}
/>
</div>
</>
<Combobox
placeholder="Chart type"
onChange={(value) => {
dispatch(changeChartType(value as IChartType));
}}
value={type}
items={Object.entries(chartTypes).map(([key, value]) => ({
label: value,
value: key,
}))}
/>
);
}

View File

@@ -1,49 +1,28 @@
import { useDispatch, useSelector } from '@/redux';
import type { IInterval } from '@/types';
import { intervals, timeRanges } from '@/utils/constants';
import { timeRanges } from '@/utils/constants';
import { Combobox } from '../ui/combobox';
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
import { changeDateRanges, changeInterval } from './reportSlice';
import { changeDateRanges } from './reportSlice';
export function ReportDateRange() {
const dispatch = useDispatch();
const range = useSelector((state) => state.report.range);
const interval = useSelector((state) => state.report.interval);
const chartType = useSelector((state) => state.report.chartType);
return (
<>
<RadioGroup>
{timeRanges.map((item) => {
return (
<RadioGroupItem
key={item.range}
active={item.range === range}
onClick={() => {
dispatch(changeDateRanges(item.range));
}}
>
{item.title}
</RadioGroupItem>
);
})}
</RadioGroup>
{chartType === 'linear' && (
<div className="w-full max-w-[200px]">
<Combobox
placeholder="Interval"
onChange={(value) => {
dispatch(changeInterval(value as IInterval));
<RadioGroup className="overflow-auto">
{timeRanges.map((item) => {
return (
<RadioGroupItem
key={item.range}
active={item.range === range}
onClick={() => {
dispatch(changeDateRanges(item.range));
}}
value={interval}
items={Object.entries(intervals).map(([key, value]) => ({
label: value,
value: key,
}))}
/>
</div>
)}
</>
>
{item.title}
</RadioGroupItem>
);
})}
</RadioGroup>
);
}

View File

@@ -0,0 +1,46 @@
import { useDispatch, useSelector } from '@/redux';
import type { IInterval } from '@/types';
import { isMinuteIntervalEnabledByRange } from '@/utils/constants';
import { Combobox } from '../ui/combobox';
import { changeInterval } from './reportSlice';
export function ReportInterval() {
const dispatch = useDispatch();
const interval = useSelector((state) => state.report.interval);
const range = useSelector((state) => state.report.range);
const chartType = useSelector((state) => state.report.chartType);
if (chartType !== 'linear') {
return null;
}
return (
<Combobox
placeholder="Interval"
onChange={(value) => {
dispatch(changeInterval(value as IInterval));
}}
value={interval}
items={[
{
value: 'minute',
label: 'Minute',
disabled: !isMinuteIntervalEnabledByRange(range),
},
{
value: 'hour',
label: 'Hour',
},
{
value: 'day',
label: 'Day',
},
{
value: 'month',
label: 'Month',
disabled: range < 1,
},
]}
/>
);
}

View File

@@ -1,12 +1,15 @@
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { pushModal } from '@/modals';
import { useSelector } from '@/redux';
import { api, handleError } from '@/utils/api';
import { SaveIcon } from 'lucide-react';
import { useReportId } from '../hooks/useReportId';
import { useReportId } from './hooks/useReportId';
export function ReportSaveButton() {
const params = useOrganizationParams();
const { reportId } = useReportId();
const update = api.report.update.useMutation({
onSuccess() {
@@ -27,10 +30,9 @@ export function ReportSaveButton() {
update.mutate({
reportId,
report,
dashboardId: '9227feb4-ad59-40f3-b887-3501685733dd',
projectId: 'f7eabf0c-e0b0-4ac0-940f-1589715b0c3d',
});
}}
icon={SaveIcon}
>
Update
</Button>
@@ -43,8 +45,9 @@ export function ReportSaveButton() {
report,
});
}}
icon={SaveIcon}
>
Create
Save
</Button>
);
}

View File

@@ -1,7 +1,6 @@
import { createContext, memo, useContext, useMemo } from 'react';
import { pick } from 'ramda';
interface ChartContextType {
export interface ChartContextType {
editMode: boolean;
}

View File

@@ -0,0 +1,30 @@
import React, { useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import type { ReportChartProps } from '.';
import { Chart } from '.';
import type { ChartContextType } from './ChartProvider';
export function LazyChart(props: ReportChartProps & ChartContextType) {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(ref, undefined, {
disconnectOnLeave: true,
});
useEffect(() => {
if (inViewport) {
once.current = true;
}
}, [inViewport]);
return (
<div ref={ref}>
{once.current || inViewport ? (
<Chart {...props} editMode={false} />
) : (
<div className="h-64 w-full bg-gray-200 animate-pulse rounded" />
)}
</div>
);
}

View File

@@ -93,9 +93,9 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
debugTable: true,
debugHeaders: true,
debugColumns: true,
// debugTable: true,
// debugHeaders: true,
// debugColumns: true,
});
return (
<div ref={ref}>

View File

@@ -40,47 +40,52 @@ export function ReportLineChart({ interval, data }: ReportLineChartProps) {
return (
<>
<AutoSizer disableHeight>
{({ width }) => (
<LineChart width={width} height={Math.min(width * 0.5, 400)}>
<YAxis dataKey={'count'} width={30} fontSize={12}></YAxis>
<Tooltip content={<ReportLineChartTooltip />} />
<CartesianGrid strokeDasharray="3 3" />
<XAxis
fontSize={12}
dataKey="date"
tickFormatter={(m: string) => {
return formatDate(m);
}}
tickLine={false}
allowDuplicatedCategory={false}
/>
{data?.series
.filter((serie) => {
return visibleSeries.includes(serie.name);
})
.map((serie) => {
const realIndex = data?.series.findIndex(
(item) => item.name === serie.name
);
const key = serie.name;
const strokeColor = getChartColor(realIndex);
return (
<Line
type="monotone"
key={key}
isAnimationActive={false}
strokeWidth={2}
dataKey="count"
stroke={strokeColor}
data={serie.data}
name={serie.name}
/>
);
})}
</LineChart>
)}
</AutoSizer>
<div className="max-sm:-mx-3">
<AutoSizer disableHeight>
{({ width }) => (
<LineChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
>
<YAxis dataKey={'count'} width={30} fontSize={12}></YAxis>
<Tooltip content={<ReportLineChartTooltip />} />
<CartesianGrid strokeDasharray="3 3" />
<XAxis
fontSize={12}
dataKey="date"
tickFormatter={(m: string) => {
return formatDate(m);
}}
tickLine={false}
allowDuplicatedCategory={false}
/>
{data?.series
.filter((serie) => {
return visibleSeries.includes(serie.name);
})
.map((serie) => {
const realIndex = data?.series.findIndex(
(item) => item.name === serie.name
);
const key = serie.name;
const strokeColor = getChartColor(realIndex);
return (
<Line
type="monotone"
key={key}
isAnimationActive={false}
strokeWidth={2}
dataKey="count"
stroke={strokeColor}
data={serie.data}
name={serie.name}
/>
);
})}
</LineChart>
)}
</AutoSizer>
</div>
{editMode && (
<ReportTable
data={data}

View File

@@ -1,3 +1,5 @@
import { memo } from 'react';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import type { IChartInput } from '@/types';
import { api } from '@/utils/api';
@@ -5,17 +7,18 @@ import { withChartProivder } from './ChartProvider';
import { ReportBarChart } from './ReportBarChart';
import { ReportLineChart } from './ReportLineChart';
type ReportLineChartProps = IChartInput;
export type ReportChartProps = IChartInput;
export const Chart = withChartProivder(
({
export const Chart = memo(
withChartProivder(function Chart({
interval,
events,
breakdowns,
chartType,
name,
range,
}: ReportLineChartProps) => {
}: ReportChartProps) {
const params = useOrganizationParams();
const hasEmptyFilters = events.some((event) =>
event.filters.some((filter) => filter.value.length === 0)
);
@@ -29,6 +32,7 @@ export const Chart = withChartProivder(
range,
startDate: null,
endDate: null,
projectSlug: params.project,
},
{
keepPreviousData: true,
@@ -63,5 +67,5 @@ export const Chart = withChartProivder(
}
return <p>Chart type &quot;{chartType}&quot; is not supported yet.</p>;
}
})
);

View File

@@ -6,7 +6,7 @@ import type {
IChartType,
IInterval,
} from '@/types';
import { alphabetIds } from '@/utils/constants';
import { alphabetIds, isMinuteIntervalEnabledByRange } from '@/utils/constants';
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
@@ -104,6 +104,13 @@ export const reportSlice = createSlice({
// Chart type
changeChartType: (state, action: PayloadAction<IChartType>) => {
state.chartType = action.payload;
if (
!isMinuteIntervalEnabledByRange(state.range) &&
state.interval === 'minute'
) {
state.interval = 'hour';
}
},
// Date range

View File

@@ -1,6 +1,6 @@
import { ColorSquare } from '@/components/ColorSquare';
import { Combobox } from '@/components/ui/combobox';
import { RenderDots } from '@/components/ui/RenderDots';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useDispatch, useSelector } from '@/redux';
import type { IChartBreakdown } from '@/types';
import { api } from '@/utils/api';
@@ -10,9 +10,12 @@ import { ReportBreakdownMore } from './ReportBreakdownMore';
import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportBreakdowns() {
const params = useOrganizationParams();
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery();
const propertiesQuery = api.chart.properties.useQuery({
projectSlug: params.project,
});
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
value: item,
label: item, // <RenderDots truncate>{item}</RenderDots>,

View File

@@ -14,6 +14,7 @@ import {
} from '@/components/ui/command';
import { RenderDots } from '@/components/ui/RenderDots';
import { useMappings } from '@/hooks/useMappings';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useDispatch } from '@/redux';
import type {
IChartEvent,
@@ -37,10 +38,12 @@ export function ReportEventFilters({
isCreating,
setIsCreating,
}: ReportEventFiltersProps) {
const params = useOrganizationParams();
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery(
{
event: event.name,
projectSlug: params.project,
},
{
enabled: !!event.name,
@@ -99,11 +102,13 @@ interface FilterProps {
}
function Filter({ filter, event }: FilterProps) {
const params = useOrganizationParams();
const getLabel = useMappings();
const dispatch = useDispatch();
const potentialValues = api.chart.values.useQuery({
event: event.name,
property: filter.name,
projectSlug: params.project,
});
const valuesCombobox =

View File

@@ -2,6 +2,7 @@ import { useState } from 'react';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { Combobox } from '@/components/ui/combobox';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useDispatch, useSelector } from '@/redux';
import type { IChartEvent } from '@/types';
import { api } from '@/utils/api';
@@ -16,7 +17,10 @@ export function ReportEvents() {
const [isCreating, setIsCreating] = useState(false);
const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch();
const eventsQuery = api.chart.events.useQuery();
const params = useOrganizationParams();
const eventsQuery = api.chart.events.useQuery({
projectSlug: params.project,
});
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
value: item.name,
label: item.name,

View File

@@ -1,13 +1,17 @@
import { Button } from '@/components/ui/button';
import { SheetClose } from '@/components/ui/sheet';
import { ReportBreakdowns } from './ReportBreakdowns';
import { ReportEvents } from './ReportEvents';
import { ReportSaveButton } from './ReportSaveButton';
export function ReportSidebar() {
return (
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-col gap-8">
<ReportEvents />
<ReportBreakdowns />
<ReportSaveButton />
<SheetClose asChild>
<Button>Done</Button>
</SheetClose>
</div>
);
}

View File

@@ -9,7 +9,7 @@ const Avatar = React.forwardRef<
<AvatarPrimitive.Root
ref={ref}
className={cn(
'relative flex h-9 w-9 shrink-0 overflow-hidden rounded-full',
'relative flex h-8 w-8 shrink-0 overflow-hidden rounded-full text-sm',
className
)}
{...props}

View File

@@ -3,6 +3,7 @@ import { cn } from '@/utils/cn';
import { Slot } from '@radix-ui/react-slot';
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import type { LucideIcon, LucideProps } from 'lucide-react';
import { Loader2 } from 'lucide-react';
const buttonVariants = cva(
@@ -39,6 +40,7 @@ interface ButtonProps
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
icon?: LucideIcon;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
@@ -51,11 +53,13 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
children,
loading,
disabled,
icon,
...props
},
ref
) => {
const Comp = asChild ? Slot : 'button';
const Icon = loading ? Loader2 : icon ?? null;
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
@@ -63,7 +67,10 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
disabled={loading ?? disabled}
{...props}
>
{loading ? <Loader2 className="animate-spin" /> : <>{children}</>}
{Icon && (
<Icon className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
)}
{children}
</Comp>
);
}

View File

@@ -15,11 +15,14 @@ import {
import { cn } from '@/utils/cn';
import { Check, ChevronsUpDown } from 'lucide-react';
import { ScrollArea } from './scroll-area';
interface ComboboxProps {
placeholder: string;
items: {
value: string;
label: string;
disabled?: boolean;
}[];
value: string;
onChange: (value: string) => void;
@@ -51,7 +54,7 @@ export function Combobox({
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full min-w-0 justify-between"
className="w-full justify-between min-w-[150px]"
>
<span className="overflow-hidden text-ellipsis">
{value ? find(value)?.label ?? 'No match' : placeholder}
@@ -82,27 +85,30 @@ export function Combobox({
) : (
<CommandEmpty>Nothing selected</CommandEmpty>
)}
<CommandGroup className="max-h-[200px] overflow-auto">
{items.map((item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
const value = find(currentValue)?.value ?? currentValue;
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>
<div className="max-h-[300px] overflow-scroll">
<CommandGroup>
{items.map((item) => (
<CommandItem
key={item.value}
value={item.value}
onSelect={(currentValue) => {
const value = find(currentValue)?.value ?? currentValue;
onChange(value);
setOpen(false);
}}
{...(item.disabled && { disabled: true })}
>
<Check
className={cn(
'mr-2 h-4 w-4 flex-shrink-0',
value === item.value ? 'opacity-100' : 'opacity-0'
)}
/>
{item.label}
</CommandItem>
))}
</CommandGroup>
</div>
</Command>
</PopoverContent>
</Popover>

View File

@@ -114,9 +114,10 @@ const CommandItem = React.forwardRef<
<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',
'relative flex cursor-pointer 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
)}
data-disabled={props.disabled}
{...props}
/>
));

View File

@@ -10,7 +10,7 @@ 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}
@@ -21,7 +21,7 @@ const PopoverContent = React.forwardRef<
)}
{...props}
/>
</PopoverPrimitive.Portal>
</>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;

View File

@@ -0,0 +1,51 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/utils/cn"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
className={cn(
"relative rounded-full bg-border",
orientation === "vertical" && "flex-1"
)}
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,138 @@
import * as React from 'react';
import { cn } from '@/utils/cn';
import * as SheetPrimitive from '@radix-ui/react-dialog';
import { cva } from 'class-variance-authority';
import type { VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
const Sheet = SheetPrimitive.Root;
const SheetTrigger = SheetPrimitive.Trigger;
const SheetClose = SheetPrimitive.Close;
const SheetPortal = SheetPrimitive.Portal;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
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}
ref={ref}
/>
));
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
const sheetVariants = cva(
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
{
variants: {
side: {
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
bottom:
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
right:
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
},
},
defaultVariants: {
side: 'right',
},
}
);
interface SheetContentProps
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
VariantProps<typeof sheetVariants> {}
const SheetContent = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Content>,
SheetContentProps
>(({ side = 'right', className, children, ...props }, ref) => (
<SheetPortal>
<SheetOverlay className="backdrop-blur-none bg-transparent" />
<SheetPrimitive.Content
ref={ref}
className={cn(sheetVariants({ side }), className)}
{...props}
>
{children}
<SheetPrimitive.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-secondary">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
));
SheetContent.displayName = SheetPrimitive.Content.displayName;
const SheetHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-2 text-center sm:text-left',
className
)}
{...props}
/>
);
SheetHeader.displayName = 'SheetHeader';
const SheetFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
SheetFooter.displayName = 'SheetFooter';
const SheetTitle = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold text-foreground', className)}
{...props}
/>
));
SheetTitle.displayName = SheetPrimitive.Title.displayName;
const SheetDescription = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
>(({ className, ...props }, ref) => (
<SheetPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
SheetDescription.displayName = SheetPrimitive.Description.displayName;
export {
Sheet,
SheetPortal,
SheetOverlay,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
};

View File

@@ -0,0 +1,29 @@
import { theme } from '@/utils/theme';
import { useMediaQuery } from 'react-responsive';
import type { ScreensConfig } from 'tailwindcss/types/config';
const breakpoints = theme?.screens ?? {
xs: '480px',
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
};
export function useBreakpoint<K extends string>(breakpointKey: K) {
const breakpointValue = breakpoints[breakpointKey as keyof ScreensConfig];
const bool = useMediaQuery({
query: `(max-width: ${breakpointValue})`,
});
const capitalizedKey =
breakpointKey[0]?.toUpperCase() + breakpointKey.substring(1);
type KeyAbove = `isAbove${Capitalize<K>}`;
type KeyBelow = `isBelow${Capitalize<K>}`;
return {
[breakpointKey]: Number(String(breakpointValue).replace(/[^0-9]/g, '')),
[`isAbove${capitalizedKey}`]: !bool,
[`isBelow${capitalizedKey}`]: bool,
} as Record<K, number> & Record<KeyAbove | KeyBelow, boolean>;
}

View File

@@ -10,7 +10,7 @@ import type { IChartInput } from '@/types';
import { api, handleError } from '@/utils/api';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/router';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { Controller, useForm } from 'react-hook-form';
import { z } from 'zod';
import { popModal } from '.';
@@ -23,7 +23,6 @@ interface SaveReportProps {
const validator = z.object({
name: z.string().min(1, 'Required'),
projectId: z.string().min(1, 'Required'),
dashboardId: z.string().min(1, 'Required'),
});
@@ -31,7 +30,7 @@ type IForm = z.infer<typeof validator>;
export default function SaveReport({ report }: SaveReportProps) {
const router = useRouter();
const { organization } = useOrganizationParams();
const { organization, project } = useOrganizationParams();
const refetch = useRefetchActive();
const save = api.report.save.useMutation({
onError: handleError,
@@ -42,7 +41,7 @@ export default function SaveReport({ report }: SaveReportProps) {
});
popModal();
refetch();
router.push(`/${organization}/reports/${res.id}`);
router.push(`/${organization}/${project}/reports/${res.id}`);
},
});
@@ -51,7 +50,6 @@ export default function SaveReport({ report }: SaveReportProps) {
resolver: zodResolver(validator),
defaultValues: {
name: '',
projectId: '',
dashboardId: '',
},
});
@@ -68,29 +66,10 @@ export default function SaveReport({ report }: SaveReportProps) {
},
});
const projectId = useWatch({
name: 'projectId',
control,
const dashboasrdQuery = api.dashboard.list.useQuery({
projectSlug: project,
});
const projectQuery = api.project.list.useQuery({
organizationSlug: organization,
});
const dashboasrdQuery = api.dashboard.list.useQuery(
{
projectId,
},
{
enabled: !!projectId,
}
);
const projects = (projectQuery.data ?? []).map((item) => ({
value: item.id,
label: item.name,
}));
const dashboards = (dashboasrdQuery.data ?? []).map((item) => ({
value: item.id,
label: item.name,
@@ -117,22 +96,6 @@ export default function SaveReport({ report }: SaveReportProps) {
{...register('name')}
defaultValue={report.name}
/>
<Controller
control={control}
name="projectId"
render={({ field }) => {
return (
<div>
<Label>Project</Label>
<Combobox
{...field}
items={projects}
placeholder="Select a project"
/>
</div>
);
}}
/>
<Controller
control={control}
name="dashboardId"
@@ -141,13 +104,12 @@ export default function SaveReport({ report }: SaveReportProps) {
<div>
<Label>Dashboard</Label>
<Combobox
disabled={!projectId}
{...field}
items={dashboards}
placeholder="Select a dashboard"
onCreate={(value) => {
dashboardMutation.mutate({
projectId,
projectSlug: project,
name: value,
});
}}

View File

@@ -1,13 +1,14 @@
import { Suspense, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { Container } from '@/components/Container';
import { MainLayout } from '@/components/layouts/MainLayout';
import { PageTitle } from '@/components/PageTitle';
import { Chart } from '@/components/report/chart';
import { LazyChart } from '@/components/report/chart/LazyChart';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { createServerSideProps } from '@/server/getServerSideProps';
import type { IChartRange } from '@/types';
import { api } from '@/utils/api';
import { cn } from '@/utils/cn';
import { timeRanges } from '@/utils/constants';
import { getRangeLabel } from '@/utils/getRangeLabel';
import Link from 'next/link';
@@ -32,59 +33,63 @@ export default function Dashboard() {
return (
<MainLayout>
<Container>
<Suspense fallback="Loading">
<PageTitle>{dashboard?.name}</PageTitle>
<PageTitle>{dashboard?.name}</PageTitle>
<RadioGroup className="mb-8">
{timeRanges.map((item) => {
return (
<RadioGroupItem
key={item.range}
active={item.range === range}
onClick={() => {
setRange((p) => (p === item.range ? null : item.range));
}}
<RadioGroup className="mb-8 overflow-auto">
{timeRanges.map((item) => {
return (
<RadioGroupItem
key={item.range}
active={item.range === range}
onClick={() => {
setRange((p) => (p === item.range ? null : item.range));
}}
>
{item.title}
</RadioGroupItem>
);
})}
</RadioGroup>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{reports.map((report) => {
const chartRange = getRangeLabel(report.range);
return (
<div
className="rounded-md border border-border bg-white shadow"
key={report.id}
>
<Link
href={`/${params.organization}/${params.project}/reports/${report.id}`}
className="block border-b border-border p-4 leading-none hover:underline"
shallow
>
{item.title}
</RadioGroupItem>
);
})}
</RadioGroup>
<div className="grid grid-cols-2 gap-4">
{reports.map((report) => {
const chartRange = getRangeLabel(report.range);
return (
<div className="font-medium">{report.name}</div>
{chartRange !== null && (
<div className="mt-2 text-sm flex gap-2">
<span className={range !== null ? 'line-through' : ''}>
{chartRange}
</span>
{range !== null && <span>{getRangeLabel(range)}</span>}
</div>
)}
</Link>
<div
className="rounded-md border border-border bg-white shadow"
key={report.id}
className={cn(
'p-4 pl-2',
report.chartType === 'bar' && 'overflow-auto max-h-[300px]'
)}
>
<Link
href={`/${params.organization}/reports/${report.id}`}
className="block border-b border-border p-4 leading-none hover:underline"
>
<div className="font-medium">{report.name}</div>
{chartRange && (
<div className="mt-2 text-sm flex gap-2">
<span className={range ? 'line-through' : ''}>
{chartRange}
</span>
{range && <span>{getRangeLabel(range)}</span>}
</div>
)}
</Link>
<div className="aspect-[1.8/1] overflow-auto p-4 pl-2">
<Chart
{...report}
range={range ?? report.range}
editMode={false}
/>
</div>
<LazyChart
{...report}
range={range ?? report.range}
editMode={false}
/>
</div>
);
})}
</div>
</Suspense>
</div>
);
})}
</div>
</Container>
</MainLayout>
);

View File

@@ -25,12 +25,13 @@ export default function Home() {
<MainLayout>
<Container>
<PageTitle>Dashboards</PageTitle>
<div className="grid grid-cols-2 gap-4">
<div className="grid sm:grid-cols-2 gap-4">
{dashboards.map((item) => (
<Card key={item.id}>
<Link
href={`/${params.organization}/${params.project}/${item.slug}`}
className="block p-4 font-medium leading-none hover:underline"
shallow
>
{item.name}
</Link>

View File

@@ -6,7 +6,9 @@ import { PageTitle } from '@/components/PageTitle';
import { usePagination } from '@/components/Pagination';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useQueryParams } from '@/hooks/useQueryParams';
import { createServerSideProps } from '@/server/getServerSideProps';
import { api } from '@/utils/api';
import { getProfileName } from '@/utils/getters';
import { z } from 'zod';
export default function ProfileId() {
@@ -17,6 +19,9 @@ export default function ProfileId() {
profileId: z.string(),
})
);
const profileQuery = api.profile.get.useQuery({
id: profileId,
});
const eventsQuery = api.event.list.useQuery(
{
projectSlug: params.project,
@@ -27,12 +32,14 @@ export default function ProfileId() {
keepPreviousData: true,
}
);
const profile = profileQuery.data ?? null;
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
return (
<MainLayout>
<Container>
<PageTitle>Profile</PageTitle>
<PageTitle>{getProfileName(profile)}</PageTitle>
<pre>{JSON.stringify(profile?.properties, null, 2)}</pre>
<EventsTable data={events} pagination={pagination} />
</Container>
</MainLayout>

View File

@@ -49,6 +49,7 @@ export default function Events() {
<Link
href={`/${params.organization}/${params.project}/profiles/${profile?.id}`}
className="flex items-center gap-2"
shallow
>
<Avatar className="h-6 w-6">
{profile?.avatar && <AvatarImage src={profile.avatar} />}
@@ -56,7 +57,16 @@ export default function Events() {
{profile?.first_name?.at(0)}
</AvatarFallback>
</Avatar>
{`${profile?.first_name} ${profile?.last_name ?? ''}`}
<div className="flex flex-col">
<div>
{[profile?.first_name, profile?.last_name]
.filter(Boolean)
.join(' ')}
</div>
<div className="text-muted-foreground text-xs">
{profile.external_id}
</div>
</div>
</Link>
);
},

View File

@@ -1,11 +1,23 @@
import { useCallback, useEffect } from 'react';
import { Container } from '@/components/Container';
import { MainLayout } from '@/components/layouts/MainLayout';
import { Chart } from '@/components/report/chart';
import { useReportId } from '@/components/report/hooks/useReportId';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportDateRange } from '@/components/report/ReportDateRange';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportSaveButton } from '@/components/report/ReportSaveButton';
import { reset, setReport } from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { Button } from '@/components/ui/button';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from '@/components/ui/sheet';
import { useRouterBeforeLeave } from '@/hooks/useRouterBeforeLeave';
import { useDispatch, useSelector } from '@/redux';
import { createServerSideProps } from '@/server/getServerSideProps';
@@ -39,18 +51,32 @@ export default function Page() {
}, [reportId, reportQuery.data, dispatch]);
return (
<MainLayout className="grid min-h-screen grid-cols-[400px_minmax(0,1fr)] divide-x">
<div>
<Sheet>
<MainLayout>
<Container>
<div className="flex flex-col gap-4 mt-8">
<div className="flex flex-col gap-4">
<ReportDateRange />
<div className="flex flex-col sm:flex-row gap-4 justify-between">
<div className="flex gap-4">
<ReportChartType />
<ReportInterval />
</div>
<div className="flex gap-4">
<SheetTrigger asChild>
<Button size="default">Select events & Filters</Button>
</SheetTrigger>
<ReportSaveButton />
</div>
</div>
</div>
<Chart {...report} editMode />
</div>
</Container>
</MainLayout>
<SheetContent className="!max-w-lg w-full">
<ReportSidebar />
</div>
<div className="flex flex-col gap-4 p-4">
<div className="flex gap-4">
<ReportDateRange />
<ReportChartType />
</div>
<Chart {...report} editMode />
</div>
</MainLayout>
</SheetContent>
</Sheet>
);
}

View File

@@ -27,12 +27,13 @@ export default function Home() {
<MainLayout>
<Container>
<PageTitle>Projects</PageTitle>
<div className="grid grid-cols-2 gap-4">
<div className="grid sm:grid-cols-2 gap-4">
{projects.map((item) => (
<Card key={item.id}>
<Link
href={`/${params.organization}/${item.slug}`}
className="block p-4 font-medium leading-none hover:underline"
shallow
>
{item.name}
</Link>

View File

@@ -8,6 +8,7 @@ import { organizationRouter } from './routers/organization';
import { profileRouter } from './routers/profile';
import { projectRouter } from './routers/project';
import { reportRouter } from './routers/report';
import { uiRouter } from './routers/ui';
import { userRouter } from './routers/user';
/**
@@ -25,6 +26,7 @@ export const appRouter = createTRPCRouter({
client: clientRouter,
event: eventRouter,
profile: profileRouter,
ui: uiRouter,
});
// export type definition of API

View File

@@ -1,5 +1,7 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import * as cache from '@/server/cache';
import { db } from '@/server/db';
import { getProjectBySlug } from '@/server/services/project.service';
import type { IChartEvent, IChartInputWithDates, IChartRange } from '@/types';
import { getDaysOldDate } from '@/utils/date';
import { toDots } from '@/utils/object';
@@ -14,28 +16,46 @@ export const config = {
};
export const chartRouter = createTRPCRouter({
events: protectedProcedure.query(async () => {
const events = await db.event.findMany({
take: 500,
distinct: ['name'],
});
events: protectedProcedure
.input(z.object({ projectSlug: z.string() }))
.query(async ({ input: { projectSlug } }) => {
const project = await getProjectBySlug(projectSlug);
const events = await cache.getOr(
`events_${project.id}`,
1000 * 60 * 60,
() =>
db.event.findMany({
take: 500,
distinct: ['name'],
where: {
project_id: project.id,
},
})
);
return events;
}),
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,
}
: {}),
},
});
.input(z.object({ event: z.string().optional(), projectSlug: z.string() }))
.query(async ({ input: { projectSlug, event } }) => {
const project = await getProjectBySlug(projectSlug);
const events = await cache.getOr(
`events_${project.id}_${event ?? 'all'}`,
1000 * 60 * 60,
() =>
db.event.findMany({
take: 500,
where: {
project_id: project.id,
...(event
? {
name: event,
}
: {}),
},
})
);
const properties = events
.reduce((acc, event) => {
@@ -53,51 +73,69 @@ export const chartRouter = createTRPCRouter({
}),
values: protectedProcedure
.input(z.object({ event: z.string(), property: z.string() }))
.query(async ({ input }) => {
if (isJsonPath(input.property)) {
.input(
z.object({
event: z.string(),
property: z.string(),
projectSlug: z.string(),
})
)
.query(async ({ input: { event, property, projectSlug } }) => {
const project = await getProjectBySlug(projectSlug);
if (isJsonPath(property)) {
const events = await db.$queryRawUnsafe<{ value: string }[]>(
`SELECT ${selectJsonPath(
input.property
)} AS value from events WHERE name = '${
input.event
}' AND "createdAt" >= NOW() - INTERVAL '30 days'`
property
)} AS value from events WHERE project_id = '${
project.id
}' AND name = '${event}' AND "createdAt" >= NOW() - INTERVAL '30 days'`
);
console.log(
`SELECT ${selectJsonPath(
property
)} AS value from events WHERE project_id = '${
project.id
}' AND name = '${event}' AND "createdAt" >= NOW() - INTERVAL '30 days'`
);
return {
values: uniq(events.map((item) => item.value)),
};
} else {
const events = await db.event.findMany({
where: {
name: input.event,
[input.property]: {
project_id: project.id,
name: event,
[property]: {
not: null,
},
createdAt: {
gte: new Date(new Date().getTime() - 1000 * 60 * 60 * 24 * 30),
},
},
distinct: input.property as any,
distinct: property as any,
select: {
[input.property]: true,
[property]: true,
},
});
return {
values: uniq(events.map((item) => item[input.property]!)),
values: uniq(events.map((item) => item[property]!)),
};
}
}),
chart: protectedProcedure
.input(zChartInputWithDates)
.query(async ({ input: { events, ...input } }) => {
.input(zChartInputWithDates.merge(z.object({ projectSlug: z.string() })))
.query(async ({ input: { projectSlug, events, ...input } }) => {
const project = await getProjectBySlug(projectSlug);
const series: Awaited<ReturnType<typeof getChartData>> = [];
for (const event of events) {
series.push(
...(await getChartData({
...input,
event,
projectId: project.id,
}))
);
}
@@ -227,9 +265,12 @@ function getChartSql({
interval,
startDate,
endDate,
}: Omit<IGetChartDataInput, 'range'>) {
projectId,
}: Omit<IGetChartDataInput, 'range'> & {
projectId: string;
}) {
const select = [];
const where = [];
const where = [`project_id = '${projectId}'`];
const groupBy = [];
const orderBy = [];
@@ -352,7 +393,10 @@ async function getChartData({
range,
startDate: _startDate,
endDate: _endDate,
}: IGetChartDataInput) {
projectId,
}: IGetChartDataInput & {
projectId: string;
}) {
const { startDate, endDate } =
_startDate && _endDate
? {
@@ -368,6 +412,7 @@ async function getChartData({
interval,
startDate,
endDate,
projectId,
});
let result = await db.$queryRawUnsafe<ResultItem[]>(sql);
@@ -381,6 +426,7 @@ async function getChartData({
interval,
startDate,
endDate,
projectId,
})
);
}
@@ -453,8 +499,8 @@ function fillEmptySpotsInTimeline(
clonedStartDate.setMinutes(0, 0, 0);
clonedEndDate.setMinutes(0, 0, 0);
} else {
clonedStartDate.setHours(2, 0, 0, 0);
clonedEndDate.setHours(2, 0, 0, 0);
clonedStartDate.setUTCHours(0, 0, 0, 0);
clonedEndDate.setUTCHours(0, 0, 0, 0);
}
// Force if interval is month and the start date is the same month as today

View File

@@ -35,14 +35,15 @@ export const dashboardRouter = createTRPCRouter({
.input(
z.object({
name: z.string(),
projectId: z.string(),
projectSlug: z.string(),
})
)
.mutation(async ({ input: { projectId, name } }) => {
.mutation(async ({ input: { projectSlug, name } }) => {
const project = await getProjectBySlug(projectSlug);
return db.dashboard.create({
data: {
slug: slug(name),
project_id: projectId,
project_id: project.id,
name,
},
});

View File

@@ -34,4 +34,17 @@ export const profileRouter = createTRPCRouter({
},
});
}),
get: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.query(async ({ input: { id } }) => {
return db.profile.findUniqueOrThrow({
where: {
id,
},
});
}),
});

View File

@@ -1,6 +1,7 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { getProjectBySlug } from '@/server/services/project.service';
import { z } from 'zod';
export const projectRouter = createTRPCRouter({
@@ -20,11 +21,17 @@ export const projectRouter = createTRPCRouter({
}),
get: protectedProcedure
.input(
z.object({
id: z.string(),
})
z
.object({
id: z.string(),
})
.or(z.object({ slug: z.string() }))
)
.query(({ input }) => {
if ('slug' in input) {
return getProjectBySlug(input.slug);
}
return db.project.findUniqueOrThrow({
where: {
id: input.id,

View File

@@ -94,14 +94,18 @@ export const reportRouter = createTRPCRouter({
.input(
z.object({
report: zChartInput,
projectId: z.string(),
dashboardId: z.string(),
})
)
.mutation(({ input: { report, projectId, dashboardId } }) => {
.mutation(async ({ input: { report, dashboardId } }) => {
const dashboard = await db.dashboard.findUniqueOrThrow({
where: {
id: dashboardId,
},
});
return db.report.create({
data: {
project_id: projectId,
project_id: dashboard.project_id,
dashboard_id: dashboardId,
name: report.name,
events: report.events,
@@ -117,18 +121,14 @@ export const reportRouter = createTRPCRouter({
z.object({
reportId: z.string(),
report: zChartInput,
projectId: z.string(),
dashboardId: z.string(),
})
)
.mutation(({ input: { report, projectId, dashboardId, reportId } }) => {
.mutation(({ input: { report, reportId } }) => {
return db.report.update({
where: {
id: reportId,
},
data: {
project_id: projectId,
dashboard_id: dashboardId,
name: report.name,
events: report.events,
interval: report.interval,

View File

@@ -0,0 +1,22 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { z } from 'zod';
export const config = {
api: {
responseLimit: false,
},
};
export const uiRouter = createTRPCRouter({
breadcrumbs: protectedProcedure
.input(
z.object({
url: z.string(),
})
)
.query(async ({ input: { url } }) => {
const parts = url.split('/').filter(Boolean);
return parts;
}),
});

View File

@@ -0,0 +1,39 @@
const cache = new Map<
string,
{
expires: number;
data: any;
}
>();
export function get(key: string) {
const hit = cache.get(key);
if (hit) {
if (hit.expires > Date.now()) {
return hit.data;
}
cache.delete(key);
}
return null;
}
export function set(key: string, expires: number, data: any) {
cache.set(key, {
expires: Date.now() + expires,
data,
});
}
export async function getOr<T>(
key: string,
expires: number,
fn: () => Promise<T>
): Promise<T> {
const hit = get(key);
if (hit) {
return hit;
}
const data = await fn();
set(key, expires, data);
return data;
}

View File

@@ -45,3 +45,7 @@ export const timeRanges = [
{ range: 180, title: '6mo' },
{ range: 365, title: '1y' },
] as const;
export function isMinuteIntervalEnabledByRange(range: number) {
return range === 0.3 || range === 0.6;
}

View File

@@ -0,0 +1,6 @@
import type { Profile } from '@prisma/client';
export function getProfileName(profile: Profile | undefined | null) {
if (!profile) return '';
return [profile.first_name, profile.last_name].filter(Boolean).join(' ');
}

View File

@@ -2,14 +2,16 @@ import resolveConfig from 'tailwindcss/resolveConfig';
import tailwinConfig from '../../tailwind.config';
const config = resolveConfig<any>(tailwinConfig);
export const resolvedTailwindConfig = resolveConfig(tailwinConfig);
export const theme = config.theme;
export const theme = resolvedTailwindConfig.theme;
export function getChartColor(index: number): string {
const chartColors: string[] = Object.keys(theme.colors ?? {})
const colors = theme?.colors ?? {};
const chartColors: string[] = Object.keys(colors)
.filter((key) => key.startsWith('chart-'))
.map((key) => theme.colors[key] as string);
.map((key) => colors[key])
.filter((item): item is string => typeof item === 'string');
return chartColors[index % chartColors.length]!;
}