server side events and ui improvemnt

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-09 15:05:59 +01:00
parent 04453c673f
commit 484a6b1d41
73 changed files with 1095 additions and 650 deletions

View File

@@ -109,7 +109,7 @@ export function ListReports({ reports }: ListReportsProps) {
</Link>
<div
className={cn(
'p-4 pl-2',
'p-4',
report.chartType === 'bar' && 'overflow-auto max-h-[300px]'
)}
>

View File

@@ -5,13 +5,13 @@ import { Card, CardActions, CardActionsItem } from '@/components/Card';
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { Button } from '@/components/ui/button';
import { ToastAction } from '@/components/ui/toast';
import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import type { IServiceDashboards } from '@/server/services/dashboard.service';
import { LayoutPanelTopIcon, Pencil, PlusIcon, Trash } from 'lucide-react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
interface ListDashboardsProps {
dashboards: IServiceDashboards;

View File

@@ -39,7 +39,7 @@ function LinkWithIcon({
return (
<Link
className={cn(
'text-slate-600 text-sm font-medium flex gap-2 items-center px-3 py-2 transition-colors hover:bg-blue-100 leading-none rounded-md transition-all',
'text-slate-800 text-sm font-medium flex gap-2 items-center px-3 py-2 transition-colors hover:bg-blue-100 leading-none rounded-md transition-all',
active && 'bg-blue-50',
className
)}
@@ -134,13 +134,14 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
<div className="flex flex-col gap-2">
{dashboards.map((item) => (
<LinkWithIcon
className="py-1"
key={item.id}
icon={LayoutPanelTopIcon}
label={
<div className="flex flex-col gap-0.5">
<div className="flex justify-between gap-0.5 items-center">
<span>{item.name}</span>
<span className="text-xs">{item.project.name}</span>
<span className="text-xs text-muted-foreground">
{item.project.name}
</span>
</div>
}
href={`/${item.organization_slug}/${item.project_id}/dashboards/${item.id}`}

View File

@@ -1,8 +1,10 @@
'use client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import type { IServiceOrganization } from '@/server/services/organization.service';
import { Building } from 'lucide-react';
import { useRouter } from 'next/navigation';
interface LayoutOrganizationSelectorProps {
organizations: IServiceOrganization[];
@@ -12,6 +14,7 @@ export default function LayoutOrganizationSelector({
organizations,
}: LayoutOrganizationSelectorProps) {
const params = useAppParams();
const router = useRouter();
const organization = organizations.find(
(item) => item.slug === params.organizationId
@@ -22,9 +25,22 @@ export default function LayoutOrganizationSelector({
}
return (
<div className="border border-border p-3 flex gap-2 rounded items-center">
<Building size={20} />
<span className="font-medium text-sm">{organization.name}</span>
</div>
<Combobox
className="w-full"
placeholder="Select organization"
icon={Building}
value={organization.slug}
items={
organizations
.filter((item) => item.slug)
.map((item) => ({
label: item.name,
value: item.slug!,
})) ?? []
}
onChange={(value) => {
router.push(`/${value}`);
}}
/>
);
}

View File

@@ -18,6 +18,7 @@ export default function LayoutProjectSelector({
return (
<div>
<Combobox
align="end"
className="w-auto min-w-0 max-sm:max-w-[100px]"
placeholder={'Select project'}
onChange={(value) => {

View File

@@ -0,0 +1,93 @@
'use client';
import { useEffect, useState } from 'react';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { useQueryClient } from '@tanstack/react-query';
import AnimatedNumbers from 'react-animated-numbers';
import { toast } from 'sonner';
import { getSafeJson } from '@mixan/common';
import type { IServiceCreateEventPayload } from '@mixan/db';
interface LiveCounterProps {
initialCount: number;
}
export function LiveCounter({ initialCount = 0 }: LiveCounterProps) {
const client = useQueryClient();
const [counter, setCounter] = useState(initialCount);
const { projectId } = useAppParams();
const [es] = useState(
typeof window != 'undefined' &&
new EventSource(`http://localhost:3333/live/events/${projectId}`)
);
useEffect(() => {
if (!es) {
return () => {};
}
function handler(event: MessageEvent<string>) {
const parsed = getSafeJson<{
visitors: number;
event: IServiceCreateEventPayload | null;
}>(event.data);
if (parsed) {
setCounter(parsed.visitors);
if (parsed.event) {
client.refetchQueries({
type: 'active',
});
toast('New event', {
description: `${parsed.event.name}`,
duration: 2000,
});
}
}
}
es.addEventListener('message', handler);
return () => es.removeEventListener('message', handler);
}, []);
return (
<Tooltip>
<TooltipTrigger>
<div className="border border-border rounded h-8 px-3 leading-none flex items-center font-medium gap-2">
<div className="relative">
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full animate-ping opacity-100 transition-all',
counter === 0 && 'bg-destructive opacity-0'
)}
></div>
<div
className={cn(
'bg-emerald-500 h-3 w-3 rounded-full absolute top-0 left-0 transition-all',
counter === 0 && 'bg-destructive'
)}
></div>
</div>
<AnimatedNumbers
includeComma
transitions={(index) => ({
type: 'spring',
duration: index + 0.3,
})}
animateToNumber={counter}
locale="en"
/>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">
{counter} unique visitors last 5 minutes
</TooltipContent>
</Tooltip>
);
}

View File

@@ -1,5 +1,6 @@
'use client';
import { Suspense } from 'react';
import { OverviewFilters } from '@/components/overview/overview-filters';
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
@@ -10,6 +11,8 @@ import OverviewTopSources from '@/components/overview/overview-top-sources';
import { WidgetHead } from '@/components/overview/overview-widget';
import { useOverviewOptions } from '@/components/overview/useOverviewOptions';
import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading';
import { MetricCardLoading } from '@/components/report/chart/MetricCard';
import { ReportRange } from '@/components/report/ReportRange';
import { Button } from '@/components/ui/button';
import {
@@ -27,6 +30,7 @@ import { Eye, FilterIcon, Globe2Icon, LockIcon, X } from 'lucide-react';
import Link from 'next/link';
import { StickyBelowHeader } from './layout-sticky-below-header';
import { LiveCounter } from './live-counter';
export default function OverviewMetrics() {
const { previous, range, setRange, interval, metric, setMetric, filters } =
@@ -200,6 +204,7 @@ export default function OverviewMetrics() {
onChange={(value) => setRange(value)}
/>
<div className="flex-wrap flex gap-2">
<LiveCounter initialCount={0} />
<OverviewFiltersButtons />
<SheetTrigger asChild>
<Button size="sm" variant="cta" icon={FilterIcon}>
@@ -240,8 +245,9 @@ export default function OverviewMetrics() {
setMetric(index);
}}
>
<Chart hideID {...report} />
<Suspense fallback={<MetricCardLoading />}>
<Chart hideID {...report} />
</Suspense>
{/* add active border */}
<div
className={cn(
@@ -256,14 +262,18 @@ export default function OverviewMetrics() {
<div className="title">{selectedMetric.events[0]?.displayName}</div>
</WidgetHead>
<WidgetBody>
<Chart hideID {...selectedMetric} chartType="linear" />
<Suspense fallback={<ChartLoading />}>
<Chart hideID {...selectedMetric} chartType="linear" />
</Suspense>
</WidgetBody>
</Widget>
<OverviewTopSources />
<OverviewTopPages />
<OverviewTopDevices />
<OverviewTopGeo />
<OverviewTopEvents />
<div className="col-span-6">
<OverviewTopGeo />
</div>
</div>
<SheetContent className="!max-w-lg w-full" side="left">

View File

@@ -1,8 +1,9 @@
'use client';
import { useEffect } from 'react';
import { Suspense, useEffect } from 'react';
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading';
import { ReportChartType } from '@/components/report/ReportChartType';
import { ReportInterval } from '@/components/report/ReportInterval';
import { ReportLineType } from '@/components/report/ReportLineType';
@@ -16,11 +17,9 @@ import {
} from '@/components/report/reportSlice';
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useDispatch, useSelector } from '@/redux';
import type { IServiceReport } from '@/server/services/reports.service';
import { timeRanges } from '@/utils/constants';
import { GanttChartSquareIcon } from 'lucide-react';
interface ReportEditorProps {
@@ -73,7 +72,11 @@ export default function ReportEditor({
</div>
</StickyBelowHeader>
<div className="flex flex-col gap-4 p-4">
{report.ready && <Chart {...report} editMode />}
{report.ready && (
<Suspense fallback={<ChartLoading />}>
<Chart {...report} editMode />
</Suspense>
)}
</div>
<SheetContent className="!max-w-lg w-full" side="left">
<ReportSidebar />

View File

@@ -3,11 +3,11 @@
import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { getOrganizationBySlug } from '@/server/services/organization.service';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const validator = z.object({

View File

@@ -1,13 +1,13 @@
import { api } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams';
import { zInviteUser } from '@/utils/validation';
import { zodResolver } from '@hookform/resolvers/zod';
import { SendIcon } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import type { z } from 'zod';
type IForm = z.infer<typeof zInviteUser>;

View File

@@ -3,12 +3,12 @@
import { api, handleError } from '@/app/_trpc/client';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { Widget, WidgetBody, WidgetHead } from '@/components/Widget';
import type { getUserById } from '@/server/services/user.service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
const validator = z.object({

View File

@@ -1,9 +1,9 @@
import type { Toast } from '@/components/ui/use-toast';
import { toast } from '@/components/ui/use-toast';
import type { AppRouter } from '@/server/api/root';
import type { TRPCClientErrorBase } from '@trpc/react-query';
import { createTRPCReact } from '@trpc/react-query';
import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import { toast } from 'sonner';
export const api = createTRPCReact<AppRouter>({});

View File

@@ -4,7 +4,9 @@ import Providers from './providers';
import '@/styles/globals.css';
export const metadata = {};
export const metadata = {
title: 'Overview - Openpanel.dev',
};
export const viewport = {
width: 'device-width',

View File

@@ -1,13 +1,14 @@
import { BoxSelectIcon } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
interface FullPageEmptyStateProps {
icon: LucideIcon;
icon?: LucideIcon;
title: string;
children: React.ReactNode;
}
export function FullPageEmptyState({
icon: Icon,
icon: Icon = BoxSelectIcon,
title,
children,
}: FullPageEmptyStateProps) {

View File

@@ -8,7 +8,7 @@ export function WidgetHead({ children, className }: WidgetHeadProps) {
return (
<div
className={cn(
'p-4 border-b border-border [&_.title]:font-medium',
'p-4 border-b border-border [&_.title]:font-medium [&_.title]:whitespace-nowrap',
className
)}
>

View File

@@ -1,6 +1,8 @@
'use client';
import { Suspense } from 'react';
import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
@@ -172,26 +174,28 @@ export default function OverviewTopDevices() {
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
// switch (widget.key) {
// case 'browser':
// setWidget('browser_version');
// // setCountry(item.name);
// break;
// case 'regions':
// setWidget('cities');
// setRegion(item.name);
// break;
// case 'cities':
// setCity(item.name);
// break;
// }
}}
/>
<Suspense fallback={<ChartLoading />}>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
// switch (widget.key) {
// case 'browser':
// setWidget('browser_version');
// // setCountry(item.name);
// break;
// case 'regions':
// setWidget('cities');
// setRegion(item.name);
// break;
// case 'cities':
// setCity(item.name);
// break;
// }
}}
/>
</Suspense>
</WidgetBody>
</Widget>
</>

View File

@@ -1,6 +1,8 @@
'use client';
import { Suspense } from 'react';
import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
@@ -67,7 +69,9 @@ export default function OverviewTopEvents() {
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart hideID {...widget.chart} previous={false} />
<Suspense fallback={<ChartLoading />}>
<Chart hideID {...widget.chart} previous={false} />
</Suspense>
</WidgetBody>
</Widget>
</>

View File

@@ -1,6 +1,8 @@
'use client';
import { Suspense } from 'react';
import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
@@ -144,26 +146,28 @@ export default function OverviewTopGeo() {
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'countries':
setWidget('regions');
setCountry(item.name);
break;
case 'regions':
setWidget('cities');
setRegion(item.name);
break;
case 'cities':
setCity(item.name);
break;
}
}}
/>
<Suspense fallback={<ChartLoading />}>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'countries':
setWidget('regions');
setCountry(item.name);
break;
case 'regions':
setWidget('cities');
setRegion(item.name);
break;
case 'cities':
setCity(item.name);
break;
}
}}
/>
</Suspense>
</WidgetBody>
</Widget>
</>

View File

@@ -1,6 +1,8 @@
'use client';
import { Suspense } from 'react';
import { Chart } from '@/components/report/chart';
import { ChartLoading } from '@/components/report/chart/ChartLoading';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
@@ -115,14 +117,16 @@ export default function OverviewTopPages() {
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
setPage(item.name);
}}
/>
<Suspense fallback={<ChartLoading />}>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
setPage(item.name);
}}
/>
</Suspense>
</WidgetBody>
</Widget>
</>

View File

@@ -1,7 +1,8 @@
'use client';
import { Suspense } from 'react';
import { Chart } from '@/components/report/chart';
import type { IChartInput } from '@/types';
import { ChartLoading } from '@/components/report/chart/ChartLoading';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../Widget';
@@ -211,33 +212,35 @@ export default function OverviewTopSources() {
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'all':
setReferrer(item.name);
break;
case 'utm_source':
setUtmSource(item.name);
break;
case 'utm_medium':
setUtmMedium(item.name);
break;
case 'utm_campaign':
setUtmCampaign(item.name);
break;
case 'utm_term':
setUtmTerm(item.name);
break;
case 'utm_content':
setUtmContent(item.name);
break;
}
}}
/>
<Suspense fallback={<ChartLoading />}>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'all':
setReferrer(item.name);
break;
case 'utm_source':
setUtmSource(item.name);
break;
case 'utm_medium':
setUtmMedium(item.name);
break;
case 'utm_campaign':
setUtmCampaign(item.name);
break;
case 'utm_term':
setUtmTerm(item.name);
break;
case 'utm_content':
setUtmContent(item.name);
break;
}
}}
/>
</Suspense>
</WidgetBody>
</Widget>
</>

View File

@@ -1,3 +1,5 @@
'use client';
import { cn } from '@/utils/cn';
import type { WidgetHeadProps } from '../Widget';

View File

@@ -2,11 +2,11 @@
import { api, handleError } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { SaveIcon } from 'lucide-react';
import { toast } from 'sonner';
import { resetDirty } from './reportSlice';

View File

@@ -0,0 +1,31 @@
import { FullPageEmptyState } from '@/components/FullPageEmptyState';
import { cn } from '@/utils/cn';
import { useChartContext } from './ChartProvider';
import { MetricCardEmpty } from './MetricCard';
export function ChartEmpty() {
const { editMode, chartType } = useChartContext();
if (editMode) {
return (
<FullPageEmptyState title="No data">
We could not find any data for selected events and filter.
</FullPageEmptyState>
);
}
if (chartType === 'metric') {
return <MetricCardEmpty />;
}
return (
<div
className={
'aspect-video w-full max-h-[400px] flex justify-center items-center'
}
>
No data
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { cn } from '@/utils/cn';
interface ChartLoadingProps {
className?: string;
}
export function ChartLoading({ className }: ChartLoadingProps) {
return (
<div
className={cn(
'aspect-video w-full bg-slate-200 animate-pulse rounded max-h-[400px] min-h-[200px]',
className
)}
/>
);
}

View File

@@ -1,10 +1,11 @@
'use client';
import React, { useEffect, useRef } from 'react';
import React, { Suspense, useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import type { ReportChartProps } from '.';
import { Chart } from '.';
import { ChartLoading } from './ChartLoading';
import type { ChartContextType } from './ChartProvider';
export function LazyChart(props: ReportChartProps & ChartContextType) {
@@ -22,11 +23,13 @@ export function LazyChart(props: ReportChartProps & ChartContextType) {
return (
<div ref={ref}>
{once.current || inViewport ? (
<Chart {...props} editMode={false} />
) : (
<div className="h-64 w-full bg-gray-200 animate-pulse rounded" />
)}
<Suspense fallback={<ChartLoading />}>
{once.current || inViewport ? (
<Chart {...props} editMode={false} />
) : (
<ChartLoading />
)}
</Suspense>
</div>
);
}

View File

@@ -25,7 +25,7 @@ export function MetricCard({
const number = useNumber();
return (
<div
className="group relative border border-border p-4 rounded-md bg-white overflow-hidden"
className="group relative border border-border p-4 rounded-md bg-white overflow-hidden h-24"
key={serie.name}
>
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-10 transition-opacity duration-300 group-hover:opacity-50">
@@ -79,3 +79,22 @@ export function MetricCard({
</div>
);
}
export function MetricCardEmpty() {
return (
<div className="border border-border p-4 rounded-md bg-white h-24">
<div className="flex items-center justify-center h-full text-slate-600">
No data
</div>
</div>
);
}
export function MetricCardLoading() {
return (
<div className="h-24 p-4 py-5 flex flex-col bg-white border border-border rounded-md">
<div className="bg-slate-200 rounded animate-pulse h-4 w-1/2"></div>
<div className="bg-slate-200 rounded animate-pulse h-6 w-1/5 mt-auto"></div>
</div>
);
}

View File

@@ -1,3 +1,4 @@
import React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
@@ -48,7 +49,7 @@ export function ReportAreaChart({
{({ width }) => (
<AreaChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
height={Math.min(Math.max(width * 0.5625, 250), 400)}
data={rechartData}
>
<Tooltip content={<ReportChartTooltip />} />
@@ -69,18 +70,41 @@ export function ReportAreaChart({
/>
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<Area
key={serie.name}
type={lineType}
isAnimationActive={false}
strokeWidth={0}
dataKey={`${serie.index}:count`}
stroke={getChartColor(serie.index)}
fill={getChartColor(serie.index)}
stackId={'1'}
fillOpacity={1}
/>
<React.Fragment key={serie.name}>
<defs>
<linearGradient
id={`color${color}`}
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor={color}
stopOpacity={0.8}
></stop>
<stop
offset="100%"
stopColor={color}
stopOpacity={0.1}
></stop>
</linearGradient>
</defs>
<Area
key={serie.name}
type={lineType}
isAnimationActive={true}
strokeWidth={2}
dataKey={`${serie.index}:count`}
stroke={color}
fill={`url(#color${color})`}
stackId={'1'}
fillOpacity={1}
/>
</React.Fragment>
);
})}
<CartesianGrid

View File

@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react';
import type { IChartData, RouterOutputs } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Progress } from '@/components/ui/progress';
import {
Table,
TableBody,
@@ -17,153 +18,121 @@ import {
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import {
createColumnHelper,
flexRender,
getCoreRowModel,
getSortedRowModel,
useReactTable,
} from '@tanstack/react-table';
import type { SortingState } from '@tanstack/react-table';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { createColumnHelper } from '@tanstack/react-table';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
const columnHelper =
createColumnHelper<RouterOutputs['chart']['chart']['series'][number]>();
interface ReportBarChartProps {
data: IChartData;
}
export function ReportBarChart({ data }: ReportBarChartProps) {
const { editMode, metric, unit, onClick } = useChartContext();
const [sorting, setSorting] = useState<SortingState>([]);
const maxCount = Math.max(
...data.series.map((serie) => serie.metrics[metric])
);
const number = useNumber();
const table = useReactTable({
data: useMemo(
() => (editMode ? data.series : data.series.slice(0, 20)),
[editMode, data]
),
columns: useMemo(() => {
return [
columnHelper.accessor((row) => row.name, {
id: 'label',
header: () => 'Label',
cell(info) {
return (
<div className="flex items-center gap-2">
<ColorSquare>{info.row.original.event.id}</ColorSquare>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="text-ellipsis overflow-hidden">
{info.getValue()}
</div>
</TooltipTrigger>
<TooltipContent>{info.getValue()}</TooltipContent>
</Tooltip>
</div>
);
},
}),
columnHelper.accessor((row) => row.metrics[metric], {
id: 'totalCount',
cell: (info) => (
<div className="flex gap-4 w-full">
<div className="relative flex-1">
<div
className="top-0 absolute shine h-[20px] rounded-full"
style={{
width: (info.getValue() / maxCount) * 100 + '%',
background: getChartColor(info.row.index),
}}
/>
</div>
<div className="font-bold">
{number.format(info.getValue())}
{unit}
</div>
<PreviousDiffIndicator
{...info.row.original.metrics.previous[metric]}
/>
</div>
),
header: () => 'Count',
enableSorting: true,
}),
];
}, [maxCount, number]),
state: {
sorting,
},
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
});
const series = useMemo(
() => (editMode ? data.series : data.series.slice(0, 20)),
[data]
);
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
return (
<Table
overflow={editMode}
className={cn('table-fixed', editMode ? '' : 'mini')}
<div
className={cn(
'flex flex-col w-full divide-y text-xs',
editMode &&
'text-base bg-white border border-border rounded-md p-4 pt-2'
)}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
{...{
colSpan: header.colSpan,
}}
>
<div
{...{
className: cn(
'flex items-center gap-2',
header.column.getCanSort() && 'cursor-pointer select-none'
),
onClick: header.column.getToggleSortingHandler(),
}}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
{{
asc: <ChevronUp className="ml-auto" size={14} />,
desc: <ChevronDown className="ml-auto" size={14} />,
}[header.column.getIsSorted() as string] ?? null}
</div>
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
{...(onClick
? {
onClick() {
onClick(row.original);
},
className: 'cursor-pointer',
}
: {})}
{editMode && (
<div className="-m-4 -mb-px flex justify-between font-medium p-4 pt-5 border-b border-border font-medium text-muted-foreground">
<div>Event</div>
<div>Count</div>
</div>
)}
{series.map((serie, index) => {
return (
<div
key={serie.name}
className="py-2 flex flex-1 w-full gap-4 items-center"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
<div className="flex-1 break-all">{serie.name}</div>
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">
<PreviousDiffIndicator {...serie.metrics.previous[metric]} />
<div className="font-bold">
{number.format(serie.metrics.sum)}
</div>
<Progress
color={getChartColor(index)}
className={cn('w-1/2', editMode ? 'h-5' : 'h-2')}
value={(serie.metrics.sum / maxCount) * 100}
/>
</div>
</div>
);
})}
</div>
);
// return (
// <Table
// overflow={editMode}
// className={cn('table-fixed', editMode ? '' : 'mini')}
// >
// <TableHeader>
// {table.getHeaderGroups().map((headerGroup) => (
// <TableRow key={headerGroup.id}>
// {headerGroup.headers.map((header) => (
// <TableHead
// key={header.id}
// {...{
// colSpan: header.colSpan,
// }}
// >
// <div
// {...{
// className: cn(
// 'flex items-center gap-2',
// header.column.getCanSort() && 'cursor-pointer select-none'
// ),
// onClick: header.column.getToggleSortingHandler(),
// }}
// >
// {flexRender(
// header.column.columnDef.header,
// header.getContext()
// )}
// {{
// asc: <ChevronUp className="ml-auto" size={14} />,
// desc: <ChevronDown className="ml-auto" size={14} />,
// }[header.column.getIsSorted() as string] ?? null}
// </div>
// </TableHead>
// ))}
// </TableRow>
// ))}
// </TableHeader>
// <TableBody>
// {table.getRowModel().rows.map((row) => (
// <TableRow
// key={row.id}
// {...(onClick
// ? {
// onClick() {
// onClick(row.original);
// },
// className: 'cursor-pointer',
// }
// : {})}
// >
// {row.getVisibleCells().map((cell) => (
// <TableCell key={cell.id}>
// {flexRender(cell.column.columnDef.cell, cell.getContext())}
// </TableCell>
// ))}
// </TableRow>
// ))}
// </TableBody>
// </Table>
// );
}

View File

@@ -19,7 +19,7 @@ export function ReportChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
const { previous, unit } = useChartContext();
const { unit } = useChartContext();
const getLabel = useMappings();
const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval(interval);
@@ -57,11 +57,6 @@ export function ReportChartTooltip({
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
{/* {previous && data.previous?.date && (
<div className="text-slate-400 italic">
{formatDate(new Date(data.previous.date))}
</div>
)} */}
</div>
)}
<div className="flex gap-2">

View File

@@ -46,7 +46,7 @@ export function ReportHistogramChart({
{({ width }) => (
<BarChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
height={Math.min(Math.max(width * 0.5625, 250), 400)}
data={rechartData}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />

View File

@@ -49,7 +49,7 @@ export function ReportLineChart({
{({ width }) => (
<LineChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
height={Math.min(Math.max(width * 0.5625, 250), 400)}
data={rechartData}
>
<CartesianGrid
@@ -80,7 +80,7 @@ export function ReportLineChart({
type={lineType}
key={serie.name}
name={serie.name}
isAnimationActive={false}
isAnimationActive={true}
strokeWidth={2}
dataKey={`${serie.index}:count`}
stroke={getChartColor(serie.index)}
@@ -90,7 +90,7 @@ export function ReportLineChart({
type={lineType}
key={`${serie.name}:prev`}
name={`${serie.name}:prev`}
isAnimationActive={false}
isAnimationActive={true}
strokeWidth={1}
dot={false}
strokeDasharray={'6 6'}

View File

@@ -39,19 +39,16 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
>
<AutoSizer disableHeight>
{({ width }) => {
const height = Math.min(Math.max(width * 0.5, 250), 400);
const height = Math.min(Math.max(width * 0.5625, 250), 400);
return (
<PieChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
>
<PieChart width={width} height={height}>
<Tooltip content={<ReportChartTooltip />} />
<Pie
dataKey={'count'}
data={pieData}
innerRadius={height / 4}
outerRadius={height / 2.5}
isAnimationActive={false}
isAnimationActive={true}
label={renderLabel}
>
{pieData.map((item) => {

View File

@@ -1,5 +1,9 @@
import { round } from '@/utils/math';
export function getYAxisWidth(value: number) {
return round(value, 0).toString().length * 7.5 + 7.5;
if (!isFinite(value)) {
return 7.8 + 7.8;
}
return round(value, 0).toString().length * 7.8 + 7.8;
}

View File

@@ -7,6 +7,7 @@ import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types';
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
import { ChartEmpty } from './ChartEmpty';
import { withChartProivder } from './ChartProvider';
import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart';
@@ -36,12 +37,7 @@ export const Chart = memo(
initialData,
}: ReportChartProps) {
const params = useAppParams();
const hasEmptyFilters = events.some((event) =>
event.filters.some((filter) => filter.value.length === 0)
);
const enabled = events.length > 0 && !hasEmptyFilters;
const chart = api.chart.chart.useQuery(
const [data] = api.chart.chart.useSuspenseQuery(
{
// dont send lineType since it does not need to be sent
lineType: 'monotone',
@@ -61,104 +57,46 @@ export const Chart = memo(
},
{
keepPreviousData: true,
enabled,
initialData,
}
);
const anyData = Boolean(chart.data?.series?.[0]?.data);
if (!enabled) {
return (
<ChartAnimationContainer>
<ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
<p className="text-center font-medium">
Please select at least one event to see the chart.
</p>
</ChartAnimationContainer>
);
}
if (chart.isLoading) {
return (
<ChartAnimationContainer>
{/* <ChartAnimation name="airplane" className="max-w-sm w-fill mx-auto" /> */}
<p className="text-center font-medium">Loading...</p>
</ChartAnimationContainer>
);
}
if (chart.isError) {
return (
<ChartAnimationContainer>
<ChartAnimation name="noData" className="max-w-sm w-fill mx-auto" />
<p className="text-center font-medium">Something went wrong...</p>
</ChartAnimationContainer>
);
}
if (!chart.isSuccess) {
return (
<ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
);
}
if (!anyData) {
return (
<ChartAnimationContainer>
<ChartAnimation name="noData" className="max-w-sm w-fill mx-auto" />
<p className="text-center font-medium">No data</p>
</ChartAnimationContainer>
);
if (data.series.length === 0) {
return <ChartEmpty />;
}
if (chartType === 'map') {
return <ReportMapChart data={chart.data} />;
return <ReportMapChart data={data} />;
}
if (chartType === 'histogram') {
return <ReportHistogramChart interval={interval} data={chart.data} />;
return <ReportHistogramChart interval={interval} data={data} />;
}
if (chartType === 'bar') {
return <ReportBarChart data={chart.data} />;
return <ReportBarChart data={data} />;
}
if (chartType === 'metric') {
return <ReportMetricChart data={chart.data} />;
return <ReportMetricChart data={data} />;
}
if (chartType === 'pie') {
return <ReportPieChart data={chart.data} />;
return <ReportPieChart data={data} />;
}
if (chartType === 'linear') {
return (
<ReportLineChart
lineType={lineType}
interval={interval}
data={chart.data}
/>
<ReportLineChart lineType={lineType} interval={interval} data={data} />
);
}
if (chartType === 'area') {
return (
<ReportAreaChart
lineType={lineType}
interval={interval}
data={chart.data}
/>
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
);
}
return (
<ChartAnimationContainer>
<ChartAnimation name="ballon" className="max-w-sm w-fill mx-auto" />
<p className="text-center font-medium">
Chart type &quot;{chartType}&quot; is not supported yet.
</p>
</ChartAnimationContainer>
);
return <p>Unknown chart type</p>;
})
);

View File

@@ -9,6 +9,7 @@ import {
CommandInput,
CommandItem,
} from '@/components/ui/command';
import { ChevronsUpDownIcon } from 'lucide-react';
import { useOnClickOutside } from 'usehooks-ts';
import { Button } from './button';
@@ -92,6 +93,7 @@ export function ComboboxAdvanced({
})}
{value.length > 2 && <Badge>+{value.length - 2} more</Badge>}
</div>
<ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full max-w-md p-0" align="start">

View File

@@ -35,6 +35,7 @@ export interface ComboboxProps<T> {
icon?: LucideIcon;
size?: ButtonProps['size'];
label?: string;
align?: 'start' | 'end' | 'center';
}
export type ExtendedComboboxProps<T> = Omit<
@@ -55,6 +56,7 @@ export function Combobox<T extends string>({
searchable,
icon: Icon,
size,
align = 'start',
}: ComboboxProps<T>) {
const [open, setOpen] = React.useState(false);
const [search, setSearch] = React.useState('');
@@ -85,7 +87,7 @@ export function Combobox<T extends string>({
</Button>
)}
</PopoverTrigger>
<PopoverContent className="w-full max-w-md p-0" align="start">
<PopoverContent className="w-full max-w-md p-0" align={align}>
<Command>
{searchable === true && (
<CommandInput

View File

@@ -0,0 +1,30 @@
import * as React from 'react';
import { cn } from '@/utils/cn';
import * as ProgressPrimitive from '@radix-ui/react-progress';
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root> & {
color: string;
}
>(({ className, value, color, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
'relative h-4 w-full overflow-hidden rounded-full bg-secondary',
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className={'h-full w-full flex-1 bg-primary transition-all'}
style={{
transform: `translateX(-${100 - (value || 0)}%)`,
background: color,
}}
/>
</ProgressPrimitive.Root>
));
Progress.displayName = ProgressPrimitive.Root.displayName;
export { Progress };

View File

@@ -0,0 +1,29 @@
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

View File

@@ -1,35 +1,31 @@
'use client';
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from '@/components/ui/toast';
import { useToast } from '@/components/ui/use-toast';
import { useTheme } from 'next-themes';
import { Toaster as Sonner } from 'sonner';
export function Toaster() {
const { toasts } = useToast();
type ToasterProps = React.ComponentProps<typeof Sonner>;
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = 'system' } = useTheme();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
<Sonner
theme={theme as ToasterProps['theme']}
className="toaster group"
toastOptions={{
classNames: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton:
'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton:
'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
},
}}
{...props}
/>
);
}
};
export { Toaster };

View File

@@ -17,17 +17,6 @@ export const env = createEnv({
NODE_ENV: z
.enum(['development', 'test', 'production'])
.default('development'),
NEXTAUTH_SECRET:
process.env.NODE_ENV === 'production'
? z.string()
: z.string().optional(),
NEXTAUTH_URL: z.preprocess(
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
// Since NextAuth.js automatically uses the VERCEL_URL if present.
(str) => process.env.VERCEL_URL ?? str,
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
process.env.VERCEL ? z.string() : z.string().url()
),
},
/**
@@ -46,8 +35,6 @@ export const env = createEnv({
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
},
/**
* Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially

View File

@@ -8,12 +8,12 @@ import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import { toast } from '@/components/ui/use-toast';
import { clipboard } from '@/utils/clipboard';
import { zodResolver } from '@hookform/resolvers/zod';
import { Copy } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { Controller, useForm, useWatch } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';

View File

@@ -4,11 +4,11 @@ import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';

View File

@@ -4,10 +4,10 @@ import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';

View File

@@ -4,11 +4,11 @@ import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import type { IClientWithProject } from '@/types';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';

View File

@@ -4,11 +4,11 @@ import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import type { IServiceDashboardWithProject } from '@/server/services/dashboard.service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';

View File

@@ -4,11 +4,11 @@ import { api, handleError } from '@/app/_trpc/client';
import { ButtonContainer } from '@/components/ButtonContainer';
import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import type { IProject } from '@/types';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';

View File

@@ -6,12 +6,12 @@ import { InputWithLabel } from '@/components/forms/InputWithLabel';
import { Button } from '@/components/ui/button';
import { Combobox } from '@/components/ui/combobox';
import { Label } from '@/components/ui/label';
import { toast } from '@/components/ui/use-toast';
import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter, useSearchParams } from 'next/navigation';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'sonner';
import { z } from 'zod';
import { popModal } from '.';

View File

@@ -411,7 +411,12 @@ function getDatesFromRange(range: IChartRange) {
let days = 1;
if (range === '24h') {
days = 1;
const startDate = getDaysOldDate(days);
const endDate = new Date();
return {
startDate: startDate.toUTCString(),
endDate: endDate.toUTCString(),
};
} else if (range === '7d') {
days = 7;
} else if (range === '14d') {

View File

@@ -1,4 +1,4 @@
import { toast } from '@/components/ui/use-toast';
import { toast } from 'sonner';
export function clipboard(value: string | number) {
navigator.clipboard.writeText(value.toString());

View File

@@ -6,8 +6,11 @@ export const round = (num: number, decimals = 2) => {
};
export const average = (arr: (number | null)[]) => {
const filtered = arr.filter(isNumber);
return filtered.reduce((p, c) => p + c, 0) / filtered.length;
const filtered = arr.filter(
(n): n is number => isNumber(n) && !Number.isNaN(n) && Number.isFinite(n)
);
const avg = filtered.reduce((p, c) => p + c, 0) / filtered.length;
return Number.isNaN(avg) ? 0 : avg;
};
export const sum = (arr: (number | null)[]): number =>