well deserved clean up (#1)

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-18 21:53:07 +01:00
parent 3a8404f704
commit b7513f24d5
106 changed files with 453 additions and 1275 deletions

View File

@@ -48,6 +48,7 @@
"@trpc/server": "^10.45.1",
"@types/d3": "^7.4.3",
"bcrypt": "^5.1.1",
"bind-event-listener": "^3.0.0",
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"cmdk": "^0.2.1",

View File

@@ -1,10 +1,9 @@
'use client';
import { api, handleErrorToastOptions } from '@/app/_trpc/client';
import { Card, CardActions, CardActionsItem } from '@/components/Card';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Card, CardActions, CardActionsItem } from '@/components/card';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Button } from '@/components/ui/button';
import { ToastAction } from '@/components/ui/toast';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';

View File

@@ -1,7 +1,7 @@
'use client';
import { Fragment } from 'react';
import { Widget, WidgetHead } from '@/components/Widget';
import { Widget, WidgetHead } from '@/components/widget';
import { isSameDay } from 'date-fns';
import type { IServiceCreateEventPayload } from '@openpanel/db';

View File

@@ -1,4 +1,4 @@
import { Widget } from '@/components/Widget';
import { Widget } from '@/components/widget';
import { db, getEvents } from '@openpanel/db';

View File

@@ -1,8 +1,8 @@
'use client';
import { Fragment, Suspense } from 'react';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination } from '@/components/Pagination';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { ChartSwitch, ChartSwitchShortcut } from '@/components/report/chart';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';

View File

@@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { Logo } from '@/components/Logo';
import { Logo } from '@/components/logo';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/utils/cn';
import { Rotate as Hamburger } from 'hamburger-react';

View File

@@ -1,16 +1,15 @@
import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { ChartSwitch } from '@/components/report/chart';
import { SerieIcon } from '@/components/report/chart/SerieIcon';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import {
eventQueryFiltersParser,
eventQueryNamesFilter,
} from '@/hooks/useEventQueryFilters';
import { getExists } from '@/server/pageExists';
import { getProfileName } from '@/utils/getters';
import { notFound } from 'next/navigation';
import { parseAsInteger, parseAsString } from 'nuqs';
@@ -20,6 +19,7 @@ import {
getEventList,
getEventsCount,
getProfileById,
getProfileName,
} from '@openpanel/db';
import type { IChartEvent, IChartInput } from '@openpanel/validation';

View File

@@ -1,74 +0,0 @@
'use client';
import { Suspense } from 'react';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination } from '@/components/Pagination';
import { Button } from '@/components/ui/button';
import { useCursor } from '@/hooks/useCursor';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { UsersIcon } from 'lucide-react';
import type { IServiceProfile } from '@openpanel/db';
import { ProfileListItem } from './profile-list-item';
interface ProfileListProps {
data: IServiceProfile[];
count: number;
}
export function ProfileList({ data, count }: ProfileListProps) {
const { cursor, setCursor } = useCursor();
const [filters] = useEventQueryFilters();
return (
<Suspense>
<div className="p-4">
{data.length === 0 ? (
<FullPageEmptyState title="No profiles here" icon={UsersIcon}>
{cursor !== 0 ? (
<>
<p>Looks like you have reached the end of the list</p>
<Button
className="mt-4"
variant="outline"
size="sm"
onClick={() => setCursor((p) => Math.max(0, p - 1))}
>
Go back
</Button>
</>
) : (
<>
{filters.length ? (
<p>Could not find any profiles with your filter</p>
) : (
<p>No profiles have been created yet</p>
)}
</>
)}
</FullPageEmptyState>
) : (
<>
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
<div className="flex flex-col gap-4 my-4">
{data.map((item) => (
<ProfileListItem key={item.id} {...item} />
))}
</div>
<Pagination
cursor={cursor}
setCursor={setCursor}
count={count}
take={50}
/>
</>
)}
</div>
</Suspense>
);
}

View File

@@ -3,7 +3,7 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { cn } from '@/utils/cn';
import { chQuery } from '@openpanel/db';

View File

@@ -1,59 +0,0 @@
'use client';
import { ExpandableListItem } from '@/components/general/ExpandableListItem';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { KeyValue, KeyValueSubtle } from '@/components/ui/key-value';
import { useAppParams } from '@/hooks/useAppParams';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { getProfileName } from '@/utils/getters';
import type { IServiceProfile } from '@openpanel/db';
type ProfileListItemProps = IServiceProfile;
export function ProfileListItem(props: ProfileListItemProps) {
const { id, properties, createdAt } = props;
const params = useAppParams();
const [, setFilter] = useEventQueryFilters({ shallow: false });
const renderContent = () => {
return (
<>
<KeyValueSubtle name="Time" value={createdAt.toLocaleString()} />
<KeyValueSubtle
href={`/${params.organizationId}/${params.projectId}/profiles/${id}`}
name="Details"
value={'See profile'}
/>
</>
);
};
return (
<ExpandableListItem
title={getProfileName(props)}
content={renderContent()}
image={<ProfileAvatar {...props} />}
>
<>
{properties && (
<div className="bg-white p-4 flex flex-col gap-4">
<div className="font-medium">Properties</div>
<div className="flex flex-wrap gap-x-4 gap-y-2">
{Object.entries(properties)
.filter(([, value]) => !!value)
.map(([key, value]) => (
<KeyValue
onClick={() => setFilter(`properties.${key}`, value)}
key={key}
name={key}
value={value}
/>
))}
</div>
</div>
)}
</>
</ExpandableListItem>
);
}

View File

@@ -1,18 +1,19 @@
'use client';
import { ListPropertiesIcon } from '@/components/events/ListPropertiesIcon';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Pagination } from '@/components/Pagination';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { Pagination } from '@/components/pagination';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { Button } from '@/components/ui/button';
import { Tooltiper } from '@/components/ui/tooltip';
import { Widget, WidgetHead } from '@/components/Widget';
import { Widget, WidgetHead } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import { useAppParams } from '@/hooks/useAppParams';
import { useCursor } from '@/hooks/useCursor';
import { UsersIcon } from 'lucide-react';
import Link from 'next/link';
import { getProfileName } from '@openpanel/db';
import type { IServiceProfile } from '@openpanel/db';
interface ProfileListProps {
@@ -49,7 +50,7 @@ export function ProfileList({ data, count }: ProfileListProps) {
className="flex gap-2 items-center font-medium"
>
<ProfileAvatar size="sm" {...profile} />
{profile.firstName} {profile.lastName}
{getProfileName(profile)}
</Link>
);
},

View File

@@ -1,10 +1,10 @@
import { ListPropertiesIcon } from '@/components/events/ListPropertiesIcon';
import { ProfileAvatar } from '@/components/profiles/ProfileAvatar';
import { Widget, WidgetHead } from '@/components/Widget';
import { ListPropertiesIcon } from '@/components/events/list-properties-icon';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { Widget, WidgetHead } from '@/components/widget';
import { WidgetTable } from '@/components/widget-table';
import Link from 'next/link';
import { chQuery, getProfiles } from '@openpanel/db';
import { chQuery, getProfileName, getProfiles } from '@openpanel/db';
interface Props {
projectId: string;
@@ -24,7 +24,7 @@ export default async function ProfileTopServer({
const list = res.map((item) => {
return {
count: item.count,
...(profiles.find((p) => p.id === item.profile_id) ?? {}),
...(profiles.find((p) => p.id === item.profile_id)! ?? {}),
};
});
@@ -35,7 +35,7 @@ export default async function ProfileTopServer({
</WidgetHead>
<WidgetTable
data={list.filter((item) => !!item.id)}
keyExtractor={(item) => item.id!}
keyExtractor={(item) => item.id}
columns={[
{
name: 'Name',
@@ -46,7 +46,7 @@ export default async function ProfileTopServer({
className="flex gap-2 items-center font-medium"
>
<ProfileAvatar size="sm" {...profile} />
{profile.firstName} {profile.lastName}
{getProfileName(profile)}
</Link>
);
},

View File

@@ -2,9 +2,8 @@
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { columns } from '@/components/clients/table';
import { DataTable } from '@/components/DataTable';
import { DataTable } from '@/components/data-table';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { PlusIcon } from 'lucide-react';
@@ -14,17 +13,12 @@ interface ListClientsProps {
clients: Awaited<ReturnType<typeof getClientsByOrganizationId>>;
}
export default function ListClients({ clients }: ListClientsProps) {
const organizationId = useAppParams().organizationId;
return (
<>
<StickyBelowHeader>
<div className="p-4 flex items-center justify-between">
<div />
<Button
icon={PlusIcon}
onClick={() => pushModal('AddClient', { organizationId })}
>
<Button icon={PlusIcon} onClick={() => pushModal('AddClient')}>
<span className="max-sm:hidden">Create client</span>
<span className="sm:hidden">Client</span>
</Button>

View File

@@ -1,9 +1,9 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';

View File

@@ -1,5 +1,5 @@
import { api } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { zodResolver } from '@hookform/resolvers/zod';

View File

@@ -8,7 +8,7 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import type { IServiceInvites } from '@openpanel/db';

View File

@@ -1,9 +1,9 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';

View File

@@ -1,7 +1,7 @@
'use client';
import { buttonVariants } from '@/components/ui/button';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
import { SignOutButton } from '@clerk/nextjs';
export function Logout() {

View File

@@ -1,7 +1,7 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { DataTable } from '@/components/DataTable';
import { DataTable } from '@/components/data-table';
import { columns } from '@/components/projects/table';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';

View File

@@ -1,7 +1,7 @@
'use client';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { DataTable } from '@/components/DataTable';
import { DataTable } from '@/components/data-table';
import { columns } from '@/components/references/table';
import { Button } from '@/components/ui/button';
import { pushModal } from '@/modals';

View File

@@ -1,21 +0,0 @@
import { Funnel } from '@/components/report/funnel';
import PageLayout from '../page-layout';
export const metadata = {
title: 'Funnel - Openpanel.dev',
};
interface PageProps {
params: {
organizationId: string;
};
}
export default function Page({ params: { organizationId } }: PageProps) {
return (
<PageLayout title="Funnel" organizationSlug={organizationId}>
<Funnel />
</PageLayout>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { LogoSquare } from '@/components/Logo';
import { LogoSquare } from '@/components/logo';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

View File

@@ -1,4 +1,4 @@
import { LogoSquare } from '@/components/Logo';
import { LogoSquare } from '@/components/logo';
import { ProjectCard } from '@/components/projects/project-card';
import { notFound, redirect } from 'next/navigation';

View File

@@ -1,8 +1,7 @@
'use client';
import { useState } from 'react';
import { CreateClientSuccess } from '@/components/clients/create-client-success';
import { LogoSquare } from '@/components/Logo';
import { LogoSquare } from '@/components/logo';
import { Button, buttonVariants } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -10,7 +9,6 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/utils/cn';
import { zodResolver } from '@hookform/resolvers/zod';
import { SaveIcon, WallpaperIcon } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';

View File

@@ -1,6 +1,6 @@
// import { CreateOrganization } from '@clerk/nextjs';
import { LogoSquare } from '@/components/Logo';
import { LogoSquare } from '@/components/logo';
import { redirect } from 'next/navigation';
import { getCurrentOrganizations, isWaitlistUserAccepted } from '@openpanel/db';

View File

@@ -1,6 +1,6 @@
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { OverviewReportRange } from '@/app/(app)/[organizationId]/[projectId]/overview-sticky-header';
import { Logo } from '@/components/Logo';
import { Logo } from '@/components/logo';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';

View File

@@ -1,11 +1,11 @@
'use client';
import { useState } from 'react';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Logo } from '@/components/Logo';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Logo } from '@/components/logo';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Widget, WidgetBody } from '@/components/Widget';
import { Widget, WidgetBody } from '@/components/widget';
import { zodResolver } from '@hookform/resolvers/zod';
import { KeySquareIcon } from 'lucide-react';
import { signIn } from 'next-auth/react';

View File

@@ -1,11 +0,0 @@
import type { HtmlProps } from '@/types';
import { cn } from '@/utils/cn';
export function Container({ className, ...props }: HtmlProps<HTMLDivElement>) {
return (
<div
className={cn('mx-auto w-full max-w-4xl px-4', className)}
{...props}
/>
);
}

View File

@@ -1,52 +0,0 @@
'use client';
import { cn } from '@/utils/cn';
interface ContentHeaderProps {
title: string;
text: string;
children?: React.ReactNode;
}
export function ContentHeader({ title, text, children }: ContentHeaderProps) {
return (
<div className="flex items-center justify-between py-6 first:pt-0">
<div>
<h2 className="h2">{title}</h2>
<p className="text-sm text-muted-foreground">{text}</p>
</div>
<div>{children}</div>
</div>
);
}
interface ContentSectionProps {
title: string;
text?: string | React.ReactNode;
children: React.ReactNode;
asCol?: boolean;
}
export function ContentSection({
title,
text,
children,
asCol,
}: ContentSectionProps) {
return (
<div
className={cn(
'first:pt-0] flex py-6',
asCol ? 'col flex' : 'justify-between'
)}
>
{title && (
<div className="max-w-[50%]">
<h4 className="h4">{title}</h4>
{text && <p className="text-sm text-muted-foreground">{text}</p>}
</div>
)}
<div>{children}</div>
</div>
);
}

View File

@@ -1,55 +0,0 @@
import { cloneElement } from 'react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from './ui/dropdown-menu';
interface DropdownProps<Value> {
children: React.ReactNode;
label?: string;
items: {
label: string;
value: Value;
}[];
onChange?: (value: Value) => void;
}
export function Dropdown<Value extends string>({
children,
label,
items,
onChange,
}: DropdownProps<Value>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
{label && (
<>
<DropdownMenuLabel>{label}</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup>
{items.map((item) => (
<DropdownMenuItem
className="cursor-pointer"
key={item.value}
onClick={() => {
onChange?.(item.value);
}}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -1,11 +0,0 @@
import type { HtmlProps } from '@/types';
type PageTitleProps = HtmlProps<HTMLDivElement>;
export function PageTitle({ children }: PageTitleProps) {
return (
<div className="my-8 flex justify-between border-b border-border py-4">
<h1 className="h1">{children}</h1>
</div>
);
}

View File

@@ -1,15 +0,0 @@
import type { HtmlProps } from '@/types';
type WithSidebarProps = HtmlProps<HTMLDivElement>;
export function WithSidebar({ children }: WithSidebarProps) {
return (
<div className="grid grid-cols-[200px_minmax(0,1fr)] gap-8">{children}</div>
);
}
type SidebarProps = HtmlProps<HTMLDivElement>;
export function Sidebar({ children }: SidebarProps) {
return <div className="flex flex-col gap-1">{children}</div>;
}

View File

@@ -3,7 +3,7 @@ import type { ColumnDef } from '@tanstack/react-table';
import type { IServiceClientWithProject } from '@openpanel/db';
import { ClientActions } from './ClientActions';
import { ClientActions } from './client-actions';
export const columns: ColumnDef<IServiceClientWithProject>[] = [
{

View File

@@ -1,11 +0,0 @@
interface InputErrorProps {
message?: string;
}
export function InputError({ message }: InputErrorProps) {
if (!message) {
return null;
}
return <div className="mt-1 text-sm text-red-600">{message}</div>;
}

View File

@@ -1,57 +0,0 @@
import React, { useState } from 'react';
import { cn } from '@/utils/cn';
import { ChevronUp } from 'lucide-react';
import AnimateHeight from 'react-animate-height';
import { Button } from '../ui/button';
interface ExpandableListItemProps {
children: React.ReactNode;
content: React.ReactNode;
title: React.ReactNode;
image?: React.ReactNode;
initialOpen?: boolean;
className?: string;
}
export function ExpandableListItem({
title,
content,
image,
initialOpen = false,
children,
className,
}: ExpandableListItemProps) {
const [open, setOpen] = useState(initialOpen ?? false);
return (
<div className={cn('card overflow-hidden', className)}>
<div className="p-2 sm:p-4 flex gap-4">
<div className="flex gap-1">{image}</div>
<div className="flex flex-col flex-1 gap-1 min-w-0">
<div className="text-md font-medium leading-none mb-1">{title}</div>
{!!content && (
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4">
{content}
</div>
)}
</div>
<Button
variant="secondary"
size="icon"
onClick={() => setOpen((p) => !p)}
className="bg-black/5 hover:bg-black/10"
>
<ChevronUp
size={20}
className={cn(
'transition-transform',
open ? 'rotate-180' : 'rotate-0'
)}
/>
</Button>
</div>
<AnimateHeight duration={200} height={open ? 'auto' : 0}>
<div className="border-t border-border">{children}</div>
</AnimateHeight>
</div>
);
}

View File

@@ -4,7 +4,7 @@ import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../../Widget';
import { Widget, WidgetBody } from '../../widget';
import { WidgetButtons, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from '../useOverviewOptions';
import { useOverviewWidget } from '../useOverviewWidget';

View File

@@ -3,7 +3,7 @@
import { WidgetHead } from '@/components/overview/overview-widget';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { ChartSwitch } from '@/components/report/chart';
import { Widget, WidgetBody } from '@/components/Widget';
import { Widget, WidgetBody } from '@/components/widget';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';

View File

@@ -4,7 +4,7 @@ import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';

View File

@@ -6,7 +6,7 @@ import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { BarChartIcon, LineChart, LineChartIcon } from 'lucide-react';
import { Widget, WidgetBody } from '../../Widget';
import { Widget, WidgetBody } from '../../widget';
import { OverviewChartToggle } from '../overview-chart-toggle';
import { WidgetButtons, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from '../useOverviewOptions';

View File

@@ -4,7 +4,7 @@ import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';

View File

@@ -4,7 +4,7 @@ import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';

View File

@@ -4,7 +4,7 @@ import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
import { Widget, WidgetBody } from '../widget';
import { OverviewChartToggle } from './overview-chart-toggle';
import { WidgetButtons, WidgetHead } from './overview-widget';
import { useOverviewOptions } from './useOverviewOptions';

View File

@@ -13,8 +13,8 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import type { WidgetHeadProps } from '../Widget';
import { WidgetHead as WidgetHeadBase } from '../Widget';
import type { WidgetHeadProps } from '../widget';
import { WidgetHead as WidgetHeadBase } from '../widget';
export function WidgetHead({ className, ...props }: WidgetHeadProps) {
return (

View File

@@ -1,13 +1,12 @@
import { formatDate } from '@/utils/date';
import type { ColumnDef } from '@tanstack/react-table';
import { IServiceProject } from '@openpanel/db';
import type { Project as IProject } from '@openpanel/db';
import type { IServiceProject } from '@openpanel/db';
import { ProjectActions } from './ProjectActions';
import { ProjectActions } from './project-actions';
export type Project = IProject;
export const columns: ColumnDef<Project>[] = [
export type Project = IServiceProject;
export const columns: ColumnDef<IServiceProject>[] = [
{
accessorKey: 'name',
header: 'Name',

View File

@@ -7,9 +7,8 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { useBreakpoint } from '@/hooks/useBreakpoint';
import { useDispatch, useSelector } from '@/redux';
import { cn } from '@/utils/cn';
import { endOfDay, format, startOfDay } from 'date-fns';
import { format } from 'date-fns';
import { CalendarIcon, ChevronsUpDownIcon } from 'lucide-react';
import type { SelectRangeEventHandler } from 'react-day-picker';
@@ -18,7 +17,6 @@ import type { IChartRange } from '@openpanel/validation';
import type { ExtendedComboboxProps } from '../ui/combobox';
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group';
import { changeDates, changeEndDate, changeStartDate } from './reportSlice';
export function ReportRange({
range,

View File

@@ -1,4 +1,4 @@
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { FullPageEmptyState } from '@/components/full-page-empty-state';
import { cn } from '@/utils/cn';
import { useChartContext } from './ChartProvider';

View File

@@ -1,7 +1,7 @@
'use client';
import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { ColorSquare } from '@/components/color-square';
import { fancyMinutes, useNumber } from '@/hooks/useNumerFormatter';
import { theme } from '@/utils/theme';
import AutoSizer from 'react-virtualized-auto-sizer';

View File

@@ -44,7 +44,6 @@ export function ReportLineChart({
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
const number = useNumber();
console.log(references.map((ref) => ref.createdAt.getTime()));
return (
<>

View File

@@ -1,5 +1,5 @@
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { AutoSizer } from '@/components/react-virtualized-auto-sizer';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';

View File

@@ -1,6 +1,6 @@
import * as React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { Pagination, usePagination } from '@/components/Pagination';
import { Pagination, usePagination } from '@/components/pagination';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
@@ -30,12 +30,14 @@ interface ReportTableProps {
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
}
const ROWS_LIMIT = 50;
export function ReportTable({
data,
visibleSeries,
setVisibleSeries,
}: ReportTableProps) {
const { setPage, paginate, page } = usePagination(50);
const { setPage, paginate, page } = usePagination(ROWS_LIMIT);
const number = useNumber();
const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval(interval);
@@ -162,7 +164,12 @@ export function ReportTable({
<Badge>Min: {number.format(data.metrics.min)}</Badge>
<Badge>Max: {number.format(data.metrics.max)}</Badge>
</div>
<Pagination cursor={page} setCursor={setPage} />
<Pagination
cursor={page}
setCursor={setPage}
take={ROWS_LIMIT}
count={data.series.length}
/>
</div>
</>
);

View File

@@ -1,7 +1,7 @@
'use client';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { ColorSquare } from '@/components/color-square';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch, useSelector } from '@/redux';

View File

@@ -1,9 +1,9 @@
'use client';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { ColorSquare } from '@/components/color-square';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/useAppParams';
import { useDebounceFn } from '@/hooks/useDebounceFn';
@@ -94,7 +94,7 @@ export function ReportEvents() {
{/* Segment and Filter buttons */}
<div className="flex gap-2 p-2 pt-0 text-sm">
<Dropdown
<DropdownMenuComposed
onChange={(segment) => {
dispatch(
changeEvent({
@@ -166,7 +166,7 @@ export function ReportEvents() {
</>
)}
</button>
</Dropdown>
</DropdownMenuComposed>
{/* */}
<FiltersCombobox event={event} />

View File

@@ -1,8 +1,8 @@
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { ColorSquare } from '@/components/color-square';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { DropdownMenuComposed } from '@/components/ui/dropdown-menu';
import { RenderDots } from '@/components/ui/RenderDots';
import { useAppParams } from '@/hooks/useAppParams';
import { useMappings } from '@/hooks/useMappings';
@@ -104,7 +104,7 @@ export function FilterItem({ filter, event }: FilterProps) {
</Button>
</div>
<div className="flex gap-1">
<Dropdown
<DropdownMenuComposed
onChange={changeFilterOperator}
items={mapKeys(operators).map((key) => ({
value: key,
@@ -115,7 +115,7 @@ export function FilterItem({ filter, event }: FilterProps) {
<Button variant={'ghost'} className="whitespace-nowrap">
{operators[filter.operator]}
</Button>
</Dropdown>
</DropdownMenuComposed>
<ComboboxAdvanced
items={valuesCombobox}
value={filter.value}

View File

@@ -180,6 +180,50 @@ const DropdownMenuShortcut = ({
};
DropdownMenuShortcut.displayName = 'DropdownMenuShortcut';
interface DropdownProps<Value> {
children: React.ReactNode;
label?: string;
items: {
label: string;
value: Value;
}[];
onChange?: (value: Value) => void;
}
export function DropdownMenuComposed<Value extends string>({
children,
label,
items,
onChange,
}: DropdownProps<Value>) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="start">
{label && (
<>
<DropdownMenuLabel>{label}</DropdownMenuLabel>
<DropdownMenuSeparator />
</>
)}
<DropdownMenuGroup>
{items.map((item) => (
<DropdownMenuItem
className="cursor-pointer"
key={item.value}
onClick={() => {
onChange?.(item.value);
}}
>
{item.label}
</DropdownMenuItem>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}
export {
DropdownMenu,
DropdownMenuTrigger,

View File

@@ -1,17 +1,16 @@
import { isValidElement } from 'react';
import { cn } from '@/utils/cn';
import Link from 'next/link';
interface KeyValueProps {
name: string;
value: unknown;
value: any;
onClick?: () => void;
href?: string;
}
export function KeyValue({ href, onClick, name, value }: KeyValueProps) {
const clickable = href || onClick;
const Component = href ? (Link as any) : onClick ? 'button' : 'div';
const Component = (href ? Link : onClick ? 'button' : 'div') as 'button';
return (
<Component
@@ -33,24 +32,3 @@ export function KeyValue({ href, onClick, name, value }: KeyValueProps) {
</Component>
);
}
export function KeyValueSubtle({ href, onClick, name, value }: KeyValueProps) {
const clickable = href || onClick;
const Component = href ? (Link as any) : onClick ? 'button' : 'div';
return (
<Component
className="group flex text-[10px] sm:text-xs gap-2 font-medium self-start min-w-0 max-w-full items-center"
{...{ href, onClick }}
>
<div className="text-gray-400">{name}</div>
<div
className={cn(
'bg-black/5 rounded p-0.5 px-1 sm:p-1 sm:px-2 text-gray-600 whitespace-nowrap text-ellipsis flex items-center gap-1',
clickable && 'group-hover:underline'
)}
>
{value}
</div>
</Component>
);
}

View File

@@ -1,9 +1,8 @@
import { api } from '@/app/_trpc/client';
export function useProfileProperties(projectId: string, event?: string) {
export function useProfileProperties(projectId: string) {
const query = api.profile.properties.useQuery({
projectId: projectId,
event,
});
return query.data ?? [];

View File

@@ -1,34 +0,0 @@
import { useMemo } from 'react';
import { useRouter } from 'next/router';
import type { z } from 'zod';
export function useQueryParams<Z extends z.ZodTypeAny = z.ZodNever>(zod: Z) {
const router = useRouter();
const value = zod.safeParse(router.query);
return useMemo(() => {
function setQueryParams(newValue: Partial<z.infer<Z>>) {
return router
.replace({
pathname: router.pathname,
query: {
...router.query,
...newValue,
},
})
.catch(() => {
// ignore
});
}
if (value.success) {
return { ...value.data, setQueryParams } as z.infer<Z> & {
setQueryParams: typeof setQueryParams;
};
}
return { ...router.query, setQueryParams } as z.infer<Z> & {
setQueryParams: typeof setQueryParams;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.asPath, value.success]);
}

View File

@@ -1,6 +0,0 @@
import { useQueryClient } from '@tanstack/react-query';
export function useRefetchActive() {
const client = useQueryClient();
return () => client.refetchQueries({ type: 'active' });
}

View File

@@ -1,21 +0,0 @@
import { useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
export function useRouterBeforeLeave(callback: () => void) {
const router = useRouter();
const prevUrl = useRef(router.asPath);
useEffect(() => {
const handleRouteChange = (url: string) => {
if (prevUrl.current !== url) {
callback();
}
prevUrl.current = url;
};
router.events.on('routeChangeStart', handleRouteChange);
return () => {
router.events.off('routeChangeStart', handleRouteChange);
};
}, [router, callback]);
}

View File

@@ -1,16 +0,0 @@
import { usePathname, useRouter } from 'next/navigation';
export function useSetCookie() {
const router = useRouter();
const pathname = usePathname();
return (key: string, value: string, path?: string) => {
fetch(`/api/cookie?${key}=${value}`).then(() => {
if (path && path !== pathname) {
router.refresh();
router.replace(path);
} else {
router.refresh();
}
});
};
}

View File

@@ -1,146 +1,6 @@
[
{
"id": "clbow140n0228u99ui1t24l85",
"name": "Mjölkproteinfritt"
},
{
"id": "cl04a3trn0015ix50tzy40pyf",
"name": "Måltider"
},
{
"id": "cl04a5p7s0401ix505n75yfcn",
"name": "Svårighetsgrad"
},
{
"id": "cl04a7k3i0813ix50aau6yxqg",
"name": "Tid"
},
{
"id": "cl04a47fu0103ix50dqckz2vc",
"name": "Frukost"
},
{
"id": "cl04a4hvu0152ix50o0w4iy8l",
"name": "Mellis"
},
{
"id": "cl04a58ju0281ix50kdmcwst6",
"name": "Dessert"
},
{
"id": "cl04a5fjc0321ix50xiwhuydy",
"name": "Smakportioner"
},
{
"id": "cl04a5kcu0361ix50bnmbhoxz",
"name": "Plockmat"
},
{
"id": "cl04a60sk0496ix50et419drf",
"name": "Medium"
},
{
"id": "cl04a67lx0536ix50sstoxnhi",
"name": "Avancerat"
},
{
"id": "cl04a7qi60850ix50je7vaxo3",
"name": "0-10 min"
},
{
"id": "cl04a7vxi0890ix50veumcuyu",
"name": "10-20 min"
},
{
"id": "cl04a82bj0930ix50bboh3tl9",
"name": "20-30 min"
},
{
"id": "cl04a8a7a0970ix50uet02cqh",
"name": "30-40 min"
},
{
"id": "cl04a8g151010ix50z4cnf2kg",
"name": "40-50 min"
},
{
"id": "cl04a8mqy1050ix50z0d1ho1a",
"name": "50-60 min"
},
{
"id": "cl04a5ujg0447ix50vd3vor87",
"name": "Lätt"
},
{
"id": "cl04a4qv60201ix50b8q5kn9r",
"name": "Lunch & Middag"
},
{
"id": "clak50jem0072ri9ugwygg5ko",
"name": "Annat"
},
{
"id": "clak510qm0120ri9upqkca39s",
"name": "För hela familjen"
},
{
"id": "clak59l8x0085yd9uzllcuci5",
"name": "Under 3 ingredienser"
},
{
"id": "clak59l8y0087yd9u53qperp8",
"name": "Under 5 ingredienser"
},
{
"id": "claslo2sg0404no9uo2tckm5i",
"name": "Huvudingredienser"
},
{
"id": "claslv9s20491no9ugo4fd9ns",
"name": "Fisk"
},
{
"id": "claslv9s30493no9umug5po29",
"name": "Kyckling"
},
{
"id": "claslv9s40495no9umor61pql",
"name": "Kött"
},
{
"id": "claslv9s40497no9uttwkt47n",
"name": "Korv"
},
{
"id": "claslv9s50499no9uch0lhs9i",
"name": "Vegetariskt"
},
{
"id": "clb1y44f40128np9uufck0iqf",
"name": "Årstider"
},
{
"id": "clb1y4ks80202np9uh43c84ts",
"name": "Jul"
},
{
"id": "clbovy0fd0081u99u8dr0yplr",
"name": "Allergier"
},
{
"id": "clbow140p0230u99uk9e7g1u1",
"name": "Äggfritt"
},
{
"id": "clbow140q0232u99uy3lwukvc",
"name": "Vetefritt"
},
{
"id": "clbow140q0234u99uiyrujxd4",
"name": "Glutenfritt"
},
{
"id": "clbow140r0236u99u5333gpei",
"name": "Nötfritt"
"id": "123",
"name": "123"
}
]

View File

@@ -1,8 +1,8 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/useAppParams';
import { zodResolver } from '@hookform/resolvers/zod';

View File

@@ -1,8 +1,8 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';

View File

@@ -1,8 +1,8 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { useAppParams } from '@/hooks/useAppParams';

View File

@@ -1,6 +1,6 @@
'use client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { ButtonContainer } from '@/components/button-container';
import { Button } from '@/components/ui/button';
import { popModal } from '.';

View File

@@ -1,8 +1,8 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';

View File

@@ -1,8 +1,8 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';

View File

@@ -1,8 +1,8 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';

View File

@@ -1,7 +1,7 @@
'use client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { zodResolver } from '@hookform/resolvers/zod';
import type { SubmitHandler } from 'react-hook-form';

View File

@@ -1,8 +1,8 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
@@ -36,7 +36,7 @@ export default function SaveReport({ report }: SaveReportProps) {
const searchParams = useSearchParams();
const dashboardId = searchParams?.get('dashboardId') ?? undefined;
const save = api.report.save.useMutation({
const save = api.report.create.useMutation({
onError: handleError,
onSuccess(res) {
toast('Success', {

View File

@@ -1,8 +1,8 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { ButtonContainer } from '@/components/button-container';
import { InputWithLabel } from '@/components/forms/input-with-label';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { useAppParams } from '@/hooks/useAppParams';

View File

@@ -11,7 +11,6 @@ import { projectRouter } from './routers/project';
import { referenceRouter } from './routers/reference';
import { reportRouter } from './routers/report';
import { shareRouter } from './routers/share';
import { uiRouter } from './routers/ui';
import { userRouter } from './routers/user';
/**
@@ -29,7 +28,6 @@ export const appRouter = createTRPCRouter({
client: clientRouter,
event: eventRouter,
profile: profileRouter,
ui: uiRouter,
share: shareRouter,
onboarding: onboardingRouter,
reference: referenceRouter,

View File

@@ -1,10 +1,17 @@
import { getDaysOldDate } from '@/utils/date';
import { round } from '@/utils/math';
import { subDays } from 'date-fns';
import * as mathjs from 'mathjs';
import { sort } from 'ramda';
import { repeat, reverse, sort } from 'ramda';
import { alphabetIds, NOT_SET_VALUE } from '@openpanel/constants';
import { chQuery, convertClickhouseDateToJs, getChartSql } from '@openpanel/db';
import {
chQuery,
convertClickhouseDateToJs,
createSqlBuilder,
formatClickhouseDate,
getChartSql,
getEventFiltersWhereClause,
} from '@openpanel/db';
import type {
IChartEvent,
IChartInput,
@@ -317,7 +324,7 @@ export function getDatesFromRange(range: IChartRange) {
let days = 1;
if (range === '24h') {
const startDate = getDaysOldDate(days);
const startDate = subDays(new Date(), days);
const endDate = new Date();
return {
startDate: startDate.toUTCString(),
@@ -337,7 +344,7 @@ export function getDatesFromRange(range: IChartRange) {
days = 365;
}
const startDate = getDaysOldDate(days);
const startDate = subDays(new Date(), days);
startDate.setUTCHours(0, 0, 0, 0);
const endDate = new Date();
endDate.setUTCHours(23, 59, 59, 999);
@@ -347,10 +354,197 @@ export function getDatesFromRange(range: IChartRange) {
};
}
export function getChartStartEndDate(
input: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>
) {
return input.startDate && input.endDate
? { startDate: input.startDate, endDate: input.endDate }
: getDatesFromRange(input.range);
export function getChartStartEndDate({
startDate,
endDate,
range,
}: Pick<IChartInput, 'endDate' | 'startDate' | 'range'>) {
return startDate && endDate
? { startDate: startDate, endDate: endDate }
: getDatesFromRange(range);
}
export function getChartPrevStartEndDate({
startDate,
endDate,
range,
}: {
startDate: string;
endDate: string;
range: IChartRange;
}) {
let diff = 0;
switch (range) {
case '30min': {
diff = 1000 * 60 * 30;
break;
}
case '1h': {
diff = 1000 * 60 * 60;
break;
}
case '24h':
case 'today': {
diff = 1000 * 60 * 60 * 24;
break;
}
case '7d': {
diff = 1000 * 60 * 60 * 24 * 7;
break;
}
case '14d': {
diff = 1000 * 60 * 60 * 24 * 14;
break;
}
case '1m': {
diff = 1000 * 60 * 60 * 24 * 30;
break;
}
case '3m': {
diff = 1000 * 60 * 60 * 24 * 90;
break;
}
case '6m': {
diff = 1000 * 60 * 60 * 24 * 180;
break;
}
}
return {
startDate: new Date(new Date(startDate).getTime() - diff).toISOString(),
endDate: new Date(new Date(endDate).getTime() - diff).toISOString(),
};
}
export async function getFunnelData({ projectId, ...payload }: IChartInput) {
const { startDate, endDate } = getChartStartEndDate(payload);
if (payload.events.length === 0) {
return {
totalSessions: 0,
steps: [],
};
}
const funnels = payload.events.map((event) => {
const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where.name = `name = '${event.name}'`;
return getWhere().replace('WHERE ', '');
});
const innerSql = `SELECT
session_id,
windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
FROM events
WHERE (project_id = '${projectId}' AND created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')
GROUP BY session_id`;
const sql = `SELECT level, count() AS count FROM (${innerSql}) GROUP BY level ORDER BY level DESC`;
const [funnelRes, sessionRes] = await Promise.all([
chQuery<{ level: number; count: number }>(sql),
chQuery<{ count: number }>(
`SELECT count(name) as count FROM events WHERE project_id = '${projectId}' AND name = 'session_start' AND (created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')`
),
]);
if (funnelRes[0]?.level !== payload.events.length) {
funnelRes.unshift({
level: payload.events.length,
count: 0,
});
}
const totalSessions = sessionRes[0]?.count ?? 0;
const filledFunnelRes = funnelRes.reduce(
(acc, item, index) => {
const diff =
index !== 0 ? (acc[acc.length - 1]?.level ?? 0) - item.level : 1;
if (diff > 1) {
acc.push(
...reverse(
repeat({}, diff - 1).map((_, index) => ({
count: acc[acc.length - 1]?.count ?? 0,
level: item.level + index + 1,
}))
)
);
}
return [
...acc,
{
count: item.count + (acc[acc.length - 1]?.count ?? 0),
level: item.level,
},
];
},
[] as typeof funnelRes
);
const steps = reverse(filledFunnelRes)
.filter((item) => item.level !== 0)
.reduce(
(acc, item, index, list) => {
const prev = list[index - 1] ?? { count: totalSessions };
return [
...acc,
{
event: payload.events[item.level - 1]!,
before: prev.count,
current: item.count,
dropoff: {
count: prev.count - item.count,
percent: 100 - (item.count / prev.count) * 100,
},
percent: (item.count / totalSessions) * 100,
prevPercent: (prev.count / totalSessions) * 100,
},
];
},
[] as {
event: IChartEvent;
before: number;
current: number;
dropoff: {
count: number;
percent: number;
};
percent: number;
prevPercent: number;
}[]
);
return {
totalSessions,
steps,
};
}
export async function getSeriesFromEvents(input: IChartInput) {
const { startDate, endDate } =
input.startDate && input.endDate
? {
startDate: input.startDate,
endDate: input.endDate,
}
: getDatesFromRange(input.range);
const series = (
await Promise.all(
input.events.map(async (event) =>
getChartData({
...input,
startDate,
endDate,
event,
})
)
)
).flat();
return withFormula(input, series);
}

View File

@@ -4,132 +4,20 @@ import {
publicProcedure,
} from '@/server/api/trpc';
import { average, max, min, round, sum } from '@/utils/math';
import { flatten, map, pipe, prop, repeat, reverse, sort, uniq } from 'ramda';
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
import { z } from 'zod';
import {
chQuery,
createSqlBuilder,
formatClickhouseDate,
getEventFiltersWhereClause,
} from '@openpanel/db';
import { chQuery, createSqlBuilder } from '@openpanel/db';
import { zChartInput } from '@openpanel/validation';
import type { IChartEvent, IChartInput } from '@openpanel/validation';
import {
getChartData,
getChartPrevStartEndDate,
getChartStartEndDate,
getDatesFromRange,
withFormula,
getFunnelData,
getSeriesFromEvents,
} from './chart.helpers';
async function getFunnelData({ projectId, ...payload }: IChartInput) {
const { startDate, endDate } = getChartStartEndDate(payload);
if (payload.events.length === 0) {
return {
totalSessions: 0,
steps: [],
};
}
const funnels = payload.events.map((event) => {
const { sb, getWhere } = createSqlBuilder();
sb.where = getEventFiltersWhereClause(event.filters);
sb.where.name = `name = '${event.name}'`;
return getWhere().replace('WHERE ', '');
});
const innerSql = `SELECT
session_id,
windowFunnel(6048000000000000,'strict_increase')(toUnixTimestamp(created_at), ${funnels.join(', ')}) AS level
FROM events
WHERE (project_id = '${projectId}' AND created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')
GROUP BY session_id`;
const sql = `SELECT level, count() AS count FROM (${innerSql}) GROUP BY level ORDER BY level DESC`;
const [funnelRes, sessionRes] = await Promise.all([
chQuery<{ level: number; count: number }>(sql),
chQuery<{ count: number }>(
`SELECT count(name) as count FROM events WHERE project_id = '${projectId}' AND name = 'session_start' AND (created_at >= '${formatClickhouseDate(startDate)}') AND (created_at <= '${formatClickhouseDate(endDate)}')`
),
]);
if (funnelRes[0]?.level !== payload.events.length) {
funnelRes.unshift({
level: payload.events.length,
count: 0,
});
}
const totalSessions = sessionRes[0]?.count ?? 0;
const filledFunnelRes = funnelRes.reduce(
(acc, item, index) => {
const diff =
index !== 0 ? (acc[acc.length - 1]?.level ?? 0) - item.level : 1;
if (diff > 1) {
acc.push(
...reverse(
repeat({}, diff - 1).map((_, index) => ({
count: acc[acc.length - 1]?.count ?? 0,
level: item.level + index + 1,
}))
)
);
}
return [
...acc,
{
count: item.count + (acc[acc.length - 1]?.count ?? 0),
level: item.level,
},
];
},
[] as typeof funnelRes
);
const steps = reverse(filledFunnelRes)
.filter((item) => item.level !== 0)
.reduce(
(acc, item, index, list) => {
const prev = list[index - 1] ?? { count: totalSessions };
return [
...acc,
{
event: payload.events[item.level - 1]!,
before: prev.count,
current: item.count,
dropoff: {
count: prev.count - item.count,
percent: 100 - (item.count / prev.count) * 100,
},
percent: (item.count / totalSessions) * 100,
prevPercent: (prev.count / totalSessions) * 100,
},
];
},
[] as {
event: IChartEvent;
before: number;
current: number;
dropoff: {
count: number;
percent: number;
};
percent: number;
prevPercent: number;
}[]
);
return {
totalSessions,
steps,
};
}
type PreviousValue = {
value: number;
diff: number | null;
@@ -243,7 +131,7 @@ export const chartRouter = createTRPCRouter({
.replace(/^properties\./, '')
.replace('.*.', '.%.')}')) as values`;
} else {
sb.select.values = `${property} as values`;
sb.select.values = `distinct ${property} as values`;
}
const events = await chQuery<{ values: string[] }>(getSql());
@@ -266,57 +154,19 @@ export const chartRouter = createTRPCRouter({
// TODO: Make this private
chart: publicProcedure.input(zChartInput).query(async ({ input }) => {
const { startDate, endDate } = getChartStartEndDate(input);
let diff = 0;
const currentPeriod = getChartStartEndDate(input);
const previousPeriod = getChartPrevStartEndDate({
range: input.range,
...currentPeriod,
});
switch (input.range) {
case '30min': {
diff = 1000 * 60 * 30;
break;
}
case '1h': {
diff = 1000 * 60 * 60;
break;
}
case '24h':
case 'today': {
diff = 1000 * 60 * 60 * 24;
break;
}
case '7d': {
diff = 1000 * 60 * 60 * 24 * 7;
break;
}
case '14d': {
diff = 1000 * 60 * 60 * 24 * 14;
break;
}
case '1m': {
diff = 1000 * 60 * 60 * 24 * 30;
break;
}
case '3m': {
diff = 1000 * 60 * 60 * 24 * 90;
break;
}
case '6m': {
diff = 1000 * 60 * 60 * 24 * 180;
break;
}
}
const promises = [getSeriesFromEvents(input)];
const promises = [getSeriesFromEvents({ ...input, ...currentPeriod })];
if (input.previous) {
promises.push(
getSeriesFromEvents({
...input,
...{
startDate: new Date(
new Date(startDate).getTime() - diff
).toISOString(),
endDate: new Date(new Date(endDate).getTime() - diff).toISOString(),
},
...previousPeriod,
})
);
}
@@ -407,11 +257,11 @@ export const chartRouter = createTRPCRouter({
final.metrics.max = max(final.series.map((item) => item.metrics.max));
final.metrics.previous = {
sum: getPreviousMetric(
sum(final.series.map((item) => item.metrics.sum)),
final.metrics.sum,
sum(final.series.map((item) => item.metrics.previous.sum?.value ?? 0))
),
average: getPreviousMetric(
round(average(final.series.map((item) => item.metrics.average)), 2),
final.metrics.average,
round(
average(
final.series.map(
@@ -422,15 +272,16 @@ export const chartRouter = createTRPCRouter({
)
),
min: getPreviousMetric(
min(final.series.map((item) => item.metrics.min)),
final.metrics.min,
min(final.series.map((item) => item.metrics.previous.min?.value ?? 0))
),
max: getPreviousMetric(
max(final.series.map((item) => item.metrics.max)),
final.metrics.max,
max(final.series.map((item) => item.metrics.previous.max?.value ?? 0))
),
};
// Sort by sum
final.series = final.series.sort((a, b) => {
if (input.chartType === 'linear') {
const sumA = a.data.reduce((acc, item) => acc + (item.count ?? 0), 0);
@@ -441,16 +292,11 @@ export const chartRouter = createTRPCRouter({
}
});
// await new Promise((res) => {
// setTimeout(() => {
// res();
// }, 100);
// });
return final;
}),
});
function getPreviousMetric(
export function getPreviousMetric(
current: number,
previous: number | null
): PreviousValue {
@@ -483,28 +329,3 @@ function getPreviousMetric(
value: previous,
};
}
async function getSeriesFromEvents(input: IChartInput) {
const { startDate, endDate } =
input.startDate && input.endDate
? {
startDate: input.startDate,
endDate: input.endDate,
}
: getDatesFromRange(input.range);
const series = (
await Promise.all(
input.events.map(async (event) =>
getChartData({
...input,
startDate,
endDate,
event,
})
)
)
).flat();
return withFormula(input, series);
}

View File

@@ -1,9 +1,9 @@
import { randomUUID } from 'crypto';
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { z } from 'zod';
import { hashPassword, stripTrailingSlash } from '@openpanel/common';
import { db, transformClient } from '@openpanel/db';
export const clientRouter = createTRPCRouter({
list: protectedProcedure
@@ -76,7 +76,7 @@ export const clientRouter = createTRPCRouter({
});
return {
...client,
...transformClient(client),
secret: input.cors ? null : secret,
};
}),

View File

@@ -1,60 +1,20 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db, getId } from '@/server/db';
import { getId } from '@/utils/getDbId';
import { PrismaError } from 'prisma-error-enum';
import { z } from 'zod';
import { db, getDashboardsByProjectId } from '@openpanel/db';
import type { Prisma } from '@openpanel/db';
export const dashboardRouter = createTRPCRouter({
get: protectedProcedure
.input(z.object({ id: z.string() }))
.query(async ({ input }) => {
return db.dashboard.findUnique({
where: {
id: input.id,
},
});
}),
list: protectedProcedure
.input(
z
.object({
projectId: z.string(),
})
.or(
z.object({
organizationId: z.string(),
})
)
z.object({
projectId: z.string(),
})
)
.query(async ({ input }) => {
if ('projectId' in input) {
return db.dashboard.findMany({
where: {
project_id: input.projectId,
},
orderBy: {
createdAt: 'desc',
},
include: {
project: true,
},
});
} else {
return db.dashboard.findMany({
where: {
project: {
organization_slug: input.organizationId,
},
},
include: {
project: true,
},
orderBy: {
createdAt: 'desc',
},
});
}
return getDashboardsByProjectId(input.projectId);
}),
create: protectedProcedure
.input(

View File

@@ -4,7 +4,12 @@ import { clerkClient } from '@clerk/nextjs';
import { z } from 'zod';
import { hashPassword, stripTrailingSlash } from '@openpanel/common';
import { db } from '@openpanel/db';
import {
db,
transformClient,
transformOrganization,
transformProject,
} from '@openpanel/db';
export const onboardingRouter = createTRPCRouter({
organziation: protectedProcedure
@@ -41,12 +46,12 @@ export const onboardingRouter = createTRPCRouter({
});
return {
client: {
client: transformClient({
...client,
secret: input.cors ? null : secret,
},
project,
organization: org,
}),
project: transformProject(project),
organization: transformOrganization(org),
};
}

View File

@@ -9,7 +9,6 @@ export const organizationRouter = createTRPCRouter({
list: protectedProcedure.query(() => {
return clerkClient.organizations.getOrganizationList();
}),
// first: protectedProcedure.query(() => getCurrentOrganization()),
get: protectedProcedure
.input(
z.object({

View File

@@ -3,71 +3,12 @@ import {
protectedProcedure,
publicProcedure,
} from '@/server/api/trpc';
import { db } from '@/server/db';
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
import { z } from 'zod';
import { chQuery, createSqlBuilder } from '@openpanel/db';
export const profileRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
query: z.string().nullable(),
projectId: z.string(),
take: z.number().default(100),
skip: z.number().default(0),
})
)
.query(async ({ input: { take, skip, projectId, query } }) => {
return db.profile.findMany({
take,
skip,
where: {
project_id: projectId,
...(query
? {
OR: [
{
first_name: {
contains: query,
mode: 'insensitive',
},
},
{
last_name: {
contains: query,
mode: 'insensitive',
},
},
{
email: {
contains: query,
mode: 'insensitive',
},
},
],
}
: {}),
},
orderBy: {
createdAt: 'desc',
},
});
}),
get: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.query(async ({ input: { id } }) => {
return db.profile.findUniqueOrThrow({
where: {
id,
},
});
}),
properties: protectedProcedure
.input(z.object({ projectId: z.string() }))
.query(async ({ input: { projectId } }) => {
@@ -88,6 +29,7 @@ export const profileRouter = createTRPCRouter({
uniq
)(properties);
}),
values: publicProcedure
.input(
z.object({

View File

@@ -1,8 +1,9 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db, getId } from '@/server/db';
import { slug } from '@/utils/slug';
import { getId } from '@/utils/getDbId';
import { z } from 'zod';
import { db, getProjectsByOrganizationSlug } from '@openpanel/db';
export const projectRouter = createTRPCRouter({
list: protectedProcedure
.input(
@@ -12,26 +13,9 @@ export const projectRouter = createTRPCRouter({
)
.query(async ({ input: { organizationId } }) => {
if (organizationId === null) return [];
return getProjectsByOrganizationSlug(organizationId);
}),
return db.project.findMany({
where: {
organization_slug: organizationId,
},
});
}),
get: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.query(({ input: { id } }) => {
return db.project.findUniqueOrThrow({
where: {
id,
},
});
}),
update: protectedProcedure
.input(
z.object({

View File

@@ -1,57 +1,11 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { z } from 'zod';
import { transformReport } from '@openpanel/db';
import { db } from '@openpanel/db';
import { zChartInput } from '@openpanel/validation';
export const reportRouter = createTRPCRouter({
get: protectedProcedure
.input(
z.object({
id: z.string(),
})
)
.query(({ input: { id } }) => {
return db.report
.findUniqueOrThrow({
where: {
id,
},
})
.then(transformReport);
}),
list: protectedProcedure
.input(
z.object({
projectId: z.string(),
dashboardId: z.string(),
})
)
.query(async ({ input: { projectId, dashboardId } }) => {
const [dashboard, reports] = await db.$transaction([
db.dashboard.findUniqueOrThrow({
where: {
id: dashboardId,
},
}),
db.report.findMany({
where: {
project_id: projectId,
dashboard_id: dashboardId,
},
orderBy: {
createdAt: 'desc',
},
}),
]);
return {
reports: reports.map(transformReport),
dashboard,
};
}),
save: protectedProcedure
create: protectedProcedure
.input(
z.object({
report: zChartInput.omit({ projectId: true }),

View File

@@ -1,7 +1,7 @@
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import ShortUniqueId from 'short-unique-id';
import { db } from '@openpanel/db';
import { zShareOverview } from '@openpanel/validation';
const uid = new ShortUniqueId({ length: 6 });

View File

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

View File

@@ -1,3 +0,0 @@
export async function getSession() {
return true;
}

View File

@@ -1,39 +0,0 @@
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

@@ -126,44 +126,6 @@
}
}
.resizer {
position: absolute;
right: 0;
top: 0;
height: 100%;
width: 5px;
background: rgba(0, 0, 0, 0.1);
cursor: col-resize;
user-select: none;
touch-action: none;
}
.resizer.isResizing {
@apply bg-black;
opacity: 1;
}
@media (hover: hover) {
.resizer {
opacity: 0;
}
*:hover > .resizer {
opacity: 1;
}
}
/* For Webkit-based browsers (Chrome, Safari and Opera) */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* For IE, Edge and Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Rechart */
.recharts-wrapper .recharts-cartesian-grid-horizontal line:first-child,

View File

@@ -1,9 +1,3 @@
export function getDaysOldDate(days: number) {
const date = new Date();
date.setDate(date.getDate() - days);
return date;
}
export function dateDifferanceInDays(date1: Date, date2: Date) {
const diffTime = Math.abs(date2.getTime() - date1.getTime());
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));

View File

@@ -1,8 +1,6 @@
import { slug } from '@/utils/slug';
import { db } from '@openpanel/db';
export { db } from '@openpanel/db';
import { slug } from './slug';
export async function getId(tableName: 'project' | 'dashboard', name: string) {
const newId = slug(name);
@@ -15,7 +13,7 @@ export async function getId(tableName: 'project' | 'dashboard', name: string) {
}
// @ts-expect-error
const existingProject = await db[tableName]!.findUnique({
const existingProject = await db[tableName].findUnique({
where: {
id: newId,
},

View File

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