redesign overview

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-04 21:43:33 +01:00
parent 4cc2f7a329
commit ef9f204006
25 changed files with 311 additions and 114 deletions

View File

@@ -58,10 +58,7 @@ export function ListReports({ reports }: ListReportsProps) {
{reports.map((report) => {
const chartRange = report.range; // timeRanges[report.range];
return (
<div
className="rounded-md border border-border bg-white shadow"
key={report.id}
>
<div className="card" key={report.id}>
<Link
href={`/${params.organizationId}/${params.projectId}/reports/${report.id}`}
className="flex border-b border-border p-4 leading-none [&_svg]:hover:opacity-100 items-center justify-between"

View File

@@ -13,9 +13,9 @@ import { getExists } from '@/server/pageExists';
import { db } from '@mixan/db';
import OverviewMetrics from '../../../../components/overview/overview-metrics';
import { CreateClient } from './create-client';
import { StickyBelowHeader } from './layout-sticky-below-header';
import OverviewMetrics from './overview-metrics';
import { OverviewReportRange } from './overview-sticky-header';
interface PageProps {

View File

@@ -1,13 +1,13 @@
import { StickyBelowHeader } from '@/app/(app)/[organizationId]/[projectId]/layout-sticky-below-header';
import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics';
import { OverviewReportRange } from '@/app/(app)/[organizationId]/[projectId]/overview-sticky-header';
import { Logo } from '@/components/Logo';
import { OverviewFiltersButtons } from '@/components/overview/filters/overview-filters-buttons';
import { OverviewFiltersDrawer } from '@/components/overview/filters/overview-filters-drawer';
import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import OverviewMetrics from '@/components/overview/overview-metrics';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events/overview-top-events';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
import OverviewTopPages from '@/components/overview/overview-top-pages';
import OverviewTopSources from '@/components/overview/overview-top-sources';
@@ -43,7 +43,7 @@ export default async function Page({ params: { id } }: PageProps) {
<Logo className="text-white" />
</a>
</div>
<div className="bg-white rounded-lg shadow ring-8 ring-blue-600/50">
<div className="bg-slate-100 rounded-lg shadow ring-8 ring-blue-600/50">
<StickyBelowHeader>
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
@@ -65,9 +65,7 @@ export default async function Page({ params: { id } }: PageProps) {
<OverviewTopPages projectId={projectId} />
<OverviewTopDevices projectId={projectId} />
<OverviewTopEvents projectId={projectId} />
<div className="col-span-6">
<OverviewTopGeo projectId={projectId} />
</div>
<OverviewTopGeo projectId={projectId} />
</div>
</div>
</div>

View File

@@ -24,7 +24,7 @@ export default function RootLayout({
return (
<html lang="en" className="light">
<body
className={cn('min-h-screen font-sans antialiased grainy bg-slate-50')}
className={cn('min-h-screen font-sans antialiased grainy bg-slate-100')}
>
<Providers>{children}</Providers>
</body>

View File

@@ -19,8 +19,8 @@ export function Card({ children, hover, className }: CardProps) {
return (
<div
className={cn(
'border border-border rounded relative bg-white',
hover && 'transition-all hover:shadow hover:border-black',
'card relative',
hover && 'transition-all hover:border-black',
className
)}
>

View File

@@ -30,14 +30,5 @@ export interface WidgetProps {
className?: string;
}
export function Widget({ children, className }: WidgetProps) {
return (
<div
className={cn(
'border border-border rounded-md bg-white self-start',
className
)}
>
{children}
</div>
);
return <div className={cn('card self-start', className)}>{children}</div>;
}

View File

@@ -23,9 +23,7 @@ export function ExpandableListItem({
}: ExpandableListItemProps) {
const [open, setOpen] = useState(initialOpen ?? false);
return (
<div
className={cn('bg-white shadow rounded-xl overflow-hidden', className)}
>
<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">

View File

@@ -0,0 +1,16 @@
import { getConversionEventNames } from '@mixan/db';
import type { OverviewLatestEventsProps } from './overview-latest-events';
import OverviewLatestEvents from './overview-latest-events';
export default async function OverviewLatestEventsServer({
projectId,
}: Omit<OverviewLatestEventsProps, 'conversions'>) {
const eventNames = await getConversionEventNames(projectId);
return (
<OverviewLatestEvents
projectId={projectId}
conversions={eventNames.map((item) => item.name)}
/>
);
}

View File

@@ -0,0 +1,128 @@
'use client';
import { ChartSwitch } from '@/components/report/chart';
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
import { cn } from '@/utils/cn';
import { Widget, WidgetBody } from '../../Widget';
import { WidgetButtons, WidgetHead } from '../overview-widget';
import { useOverviewOptions } from '../useOverviewOptions';
import { useOverviewWidget } from '../useOverviewWidget';
export interface OverviewLatestEventsProps {
projectId: string;
conversions: string[];
}
export default function OverviewLatestEvents({
projectId,
conversions,
}: OverviewLatestEventsProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters] = useEventQueryFilters();
const [widget, setWidget, widgets] = useOverviewWidget('ev', {
all: {
title: 'Top events',
btn: 'All',
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'ex_session',
name: 'name',
operator: 'isNot',
value: ['session_start', 'session_end'],
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
conversions: {
title: 'Conversions',
btn: 'Conversions',
hide: conversions.length === 0,
chart: {
projectId,
startDate,
endDate,
events: [
{
segment: 'event',
filters: [
...filters,
{
id: 'conversion',
name: 'name',
operator: 'is',
value: conversions,
},
],
id: 'A',
name: '*',
},
],
breakdowns: [
{
id: 'A',
name: 'name',
},
],
chartType: 'bar',
lineType: 'monotone',
interval: interval,
name: 'Top sources',
range: range,
previous: previous,
metric: 'sum',
},
},
});
return (
<>
<Widget className="col-span-6 md:col-span-3">
<WidgetHead>
<div className="title">{widget.title}</div>
<WidgetButtons>
{widgets
.filter((item) => item.hide !== true)
.map((w) => (
<button
key={w.key}
onClick={() => setWidget(w.key)}
className={cn(w.key === widget.key && 'active')}
>
{w.btn}
</button>
))}
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<ChartSwitch hideID {...widget.chart} previous={false} />
</WidgetBody>
</Widget>
</>
);
}

View File

@@ -1,22 +1,25 @@
'use client';
import { Fragment } from 'react';
import { api } from '@/app/_trpc/client';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon } from 'lucide-react';
import AnimateHeight from 'react-animate-height';
import type { IChartInput } from '@mixan/validation';
import { ChartSwitch } from '../report/chart';
import { Widget, WidgetBody, WidgetHead } from '../Widget';
import { redisSub } from '../../../../../packages/redis';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewLiveHistogramProps {
projectId: string;
}
export function OverviewLiveHistogram({
projectId,
}: OverviewLiveHistogramProps) {
const { liveHistogram, setLiveHistogram } = useOverviewOptions();
const { liveHistogram } = useOverviewOptions();
const report: IChartInput = {
projectId,
events: [
@@ -44,26 +47,112 @@ export function OverviewLiveHistogram({
lineType: 'monotone',
previous: false,
};
const countReport: IChartInput = {
name: '',
projectId,
events: [
{
segment: 'user',
filters: [],
id: 'A',
name: 'session_start',
},
],
breakdowns: [],
chartType: 'metric',
lineType: 'monotone',
interval: 'minute',
range: '30min',
previous: false,
metric: 'sum',
};
const res = api.chart.chart.useQuery(report);
const countRes = api.chart.chart.useQuery(countReport);
const metrics = res.data?.series[0]?.metrics;
const minutes = (res.data?.series[0]?.data || []).slice(-30);
const liveCount = countRes.data?.series[0]?.metrics?.sum ?? 0;
if (res.isInitialLoading || countRes.isInitialLoading) {
// prettier-ignore
const staticArray = [
10, 25, 30, 45, 20, 5, 55, 18, 40, 12,
50, 35, 8, 22, 38, 42, 15, 28, 52, 5,
48, 14, 32, 58, 7, 19, 33, 56, 24, 5
];
return (
<Wrapper count={0} open={liveHistogram}>
{staticArray.map((percent, i) => (
<div
key={i}
className="flex-1 rounded-md bg-slate-200 animate-pulse"
style={{ height: `${percent}%` }}
/>
))}
</Wrapper>
);
}
if (!res.isSuccess && !countRes.isSuccess) {
return null;
}
return (
<Widget>
<button onClick={() => setLiveHistogram((p) => !p)} className="w-full">
<WidgetHead
className={cn(
'flex justify-between items-center',
!liveHistogram && 'border-b-0'
)}
>
<div className="title">Active users last 30 minutes</div>
<ChevronsUpDownIcon size={16} />
</WidgetHead>
</button>
<AnimateHeight duration={500} height={liveHistogram ? 'auto' : 0}>
<WidgetBody>
<ChartSwitch {...report} />
</WidgetBody>
</AnimateHeight>
</Widget>
<Wrapper open={liveHistogram} count={liveCount}>
{minutes.map((minute) => {
return (
<Tooltip key={minute.date}>
<TooltipTrigger asChild>
<div
className={cn(
'flex-1 rounded-md hover:scale-110 transition-all ease-in-out',
minute.count === 0 ? 'bg-slate-200' : 'bg-blue-600'
)}
style={{
height:
minute.count === 0
? '5%'
: `${(minute.count / metrics!.max) * 100}%`,
}}
/>
</TooltipTrigger>
<TooltipContent side="top">
<div>{minute.count} active users</div>
<div>@ {new Date(minute.date).toLocaleTimeString()}</div>
</TooltipContent>
</Tooltip>
);
})}
</Wrapper>
);
}
interface WrapperProps {
open: boolean;
children: React.ReactNode;
count: number;
}
function Wrapper({ open, children, count }: WrapperProps) {
return (
<AnimateHeight duration={500} height={open ? 'auto' : 0}>
<div className="flex items-end flex-col md:flex-row">
<div className="md:mr-2 flex md:flex-col max-md:justify-between items-end max-md:w-full max-md:mb-2 md:card md:p-4">
<div className="text-sm max-md:mb-1">Last 30 minutes</div>
<div className="text-2xl font-bold text-ellipsis overflow-hidden whitespace-nowrap">
{count}
</div>
</div>
<div className="max-h-[150px] aspect-[5/1] flex flex-1 gap-0.5 md:gap-2 items-end w-full relative">
<div className="absolute top-0 right-0 text-xs text-muted-foreground">
NOW
</div>
{/* <div className="md:absolute top-0 left-0 md:card md:p-4 mr-2 md:bg-white/90 z-50"> */}
{children}
</div>
</div>
</AnimateHeight>
);
}

View File

@@ -202,12 +202,6 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
}}
>
<ChartSwitch hideID {...report} />
<div
className={cn(
'transition-opacity top-0 left-0 right-0 bottom-0 absolute rounded-md w-full h-full border ring-1 border-black ring-black',
metric === index ? 'opacity-100' : 'opacity-0'
)}
/>
{/* add active border */}
</button>
))}

View File

@@ -18,6 +18,7 @@ export default function OverviewTopDevices({
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: {
title: 'Top devices',
@@ -31,7 +32,7 @@ export default function OverviewTopDevices({
segment: 'user',
filters,
id: 'A',
name: '*',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
@@ -61,7 +62,7 @@ export default function OverviewTopDevices({
segment: 'user',
filters,
id: 'A',
name: '*',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
@@ -91,7 +92,7 @@ export default function OverviewTopDevices({
segment: 'user',
filters,
id: 'A',
name: '*',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
@@ -121,7 +122,7 @@ export default function OverviewTopDevices({
segment: 'user',
filters,
id: 'A',
name: '*',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
@@ -151,7 +152,7 @@ export default function OverviewTopDevices({
segment: 'user',
filters,
id: 'A',
name: '*',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [

View File

@@ -16,6 +16,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
const { interval, range, previous, startDate, endDate } =
useOverviewOptions();
const [filters, setFilter] = useEventQueryFilters();
const isPageFilter = filters.find((filter) => filter.name === 'path');
const [widget, setWidget, widgets] = useOverviewWidget('geo', {
countries: {
title: 'Top countries',
@@ -29,7 +30,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
segment: 'event',
filters,
id: 'A',
name: '*',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
@@ -59,7 +60,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
segment: 'event',
filters,
id: 'A',
name: '*',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
@@ -89,7 +90,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
segment: 'event',
filters,
id: 'A',
name: '*',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [
@@ -165,7 +166,7 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
segment: 'event',
filters,
id: 'A',
name: '*',
name: isPageFilter ? 'screen_view' : 'session_start',
},
],
breakdowns: [

View File

@@ -19,10 +19,7 @@ import { WidgetHead as WidgetHeadBase } from '../Widget';
export function WidgetHead({ className, ...props }: WidgetHeadProps) {
return (
<WidgetHeadBase
className={cn(
'flex flex-col p-0 [&_.title]:text-sm [&_.title]:px-4 [&_.title]:py-2',
className
)}
className={cn('flex flex-col p-0 [&_.title]:p-4', className)}
{...props}
/>
);

View File

@@ -1,4 +1,3 @@
import { useEffect } from 'react';
import {
parseAsBoolean,
parseAsInteger,
@@ -48,7 +47,7 @@ export function useOverviewOptions() {
// Toggles
const [liveHistogram, setLiveHistogram] = useQueryState(
'live',
parseAsBoolean.withDefault(false).withOptions(nuqsOptions)
parseAsBoolean.withDefault(true).withOptions(nuqsOptions)
);
return {

View File

@@ -107,7 +107,7 @@ export function PreviousDiffIndicatorText({
])}
>
{renderIcon()}
{number.format(diff)}%
{number.short(diff)}%
</div>
);
}

View File

@@ -56,7 +56,7 @@ export function MetricCard({
return (
<div
className="group relative border border-border p-2 rounded-md bg-white overflow-hidden h-24"
className="group relative card p-4 overflow-hidden h-24"
key={serie.name}
>
<div className="absolute -top-2 -left-2 -right-2 -bottom-2 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50 rounded-md">
@@ -64,28 +64,14 @@ export function MetricCard({
{({ width, height }) => (
<AreaChart
width={width}
height={height / 3}
height={height / 4}
data={serie.data}
style={{ marginTop: (height / 3) * 2 }}
style={{ marginTop: (height / 4) * 3 }}
>
<defs>
<linearGradient id="red" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={'red'} stopOpacity={0.5} />
<stop offset="95%" stopColor={'red'} stopOpacity={0.2} />
</linearGradient>
<linearGradient id="green" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={'green'} stopOpacity={0.5} />
<stop offset="95%" stopColor={'green'} stopOpacity={0.2} />
</linearGradient>
<linearGradient id="blue" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={'blue'} stopOpacity={0.5} />
<stop offset="95%" stopColor={'blue'} stopOpacity={0.2} />
</linearGradient>
</defs>
<Area
dataKey="count"
type="monotone"
fill={`url(#${graphColors})`}
fill={`transparent`}
fillOpacity={1}
stroke={graphColors}
strokeWidth={2}
@@ -120,7 +106,7 @@ export function MetricCard({
export function MetricCardEmpty() {
return (
<div className="border border-border p-4 rounded-md bg-white h-24">
<div className="card p-4 h-24">
<div className="flex items-center justify-center h-full text-slate-600">
No data
</div>
@@ -130,7 +116,7 @@ export function MetricCardEmpty() {
export function MetricCardLoading() {
return (
<div className="h-24 p-4 py-5 flex flex-col bg-white border border-border rounded-md">
<div className="h-24 p-4 py-5 flex flex-col card">
<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

@@ -30,16 +30,17 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
<div
className={cn(
'flex flex-col w-full text-xs -mx-2',
editMode && 'text-base bg-white border border-border rounded-md p-4'
editMode && 'text-base card p-4'
)}
>
{series.map((serie) => {
{series.map((serie, index) => {
const isClickable = serie.name !== NOT_SET_VALUE && onClick;
return (
<div
key={serie.name}
className={cn(
'relative py-3 px-2 flex flex-1 w-full gap-4 items-center even:bg-slate-50 [&_[role=progressbar]]:even:bg-white [&_[role=progressbar]]:shadow-sm rounded overflow-hidden',
'relative py-3 px-2 flex flex-1 w-full gap-4 items-center even:bg-slate-50 rounded overflow-hidden',
'[&_[role=progressbar]]:even:bg-white [&_[role=progressbar]]:shadow-sm',
isClickable && 'cursor-pointer hover:!bg-slate-100'
)}
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
@@ -57,14 +58,12 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
<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
className="absolute left-0 bottom-0 h-0.5 rounded-full min-w-2 z-10 bg-blue-600"
style={{
width: `${(serie.metrics.sum / maxCount) * 100}%`,
}}
></div>
</div>
);
})}

View File

@@ -31,12 +31,7 @@ export function ReportPieChart({ data }: ReportPieChartProps) {
return (
<>
<div
className={cn(
'max-sm:-mx-3',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<div className={cn('max-sm:-mx-3', editMode && 'card p-4')}>
<AutoSizer disableHeight>
{({ width }) => {
const height = Math.min(Math.max(width * 0.5625, 250), 400);

View File

@@ -17,10 +17,7 @@ export function ResponsiveContainer({ children }: ResponsiveContainerProps) {
maxHeight,
minHeight,
}}
className={cn(
'max-sm:-mx-3 aspect-video w-full',
editMode && 'border border-border bg-white rounded-md p-4'
)}
className={cn('max-sm:-mx-3 aspect-video w-full', editMode && 'card p-4')}
>
<AutoSizer disableHeight>
{({ width }) =>

View File

@@ -44,6 +44,11 @@ const mapper: Record<string, LucideIcon> = {
link_out: ExternalLinkIcon,
// Websites
linkedin: createImageIcon(getProxyImage('https://linkedin.com')),
slack: createImageIcon(getProxyImage('https://slack.com')),
pinterest: createImageIcon(getProxyImage('https://www.pinterest.se')),
ecosia: createImageIcon(getProxyImage('https://ecosia.com')),
yandex: createImageIcon(getProxyImage('https://yandex.com')),
google: createImageIcon(getProxyImage('https://google.com')),
facebook: createImageIcon(getProxyImage('https://facebook.com')),
bing: createImageIcon(getProxyImage('https://bing.com')),

View File

@@ -99,7 +99,7 @@ export function FunnelSteps({
)}
key={step.event.id}
>
<div className="border border-border divide-y divide-border bg-white">
<div className="card divide-y divide-border bg-white">
<div className="p-4">
<p className="text-muted-foreground">Step {index + 1}</p>
<h3 className="font-bold">

View File

@@ -10,7 +10,7 @@ const Table = React.forwardRef<
overflow?: boolean;
}
>(({ className, wrapper, overflow = true, ...props }, ref) => (
<div className={cn('border border-border rounded-md bg-white', className)}>
<div className={cn('card', className)}>
<div className={cn('relative w-full', overflow && 'overflow-auto')}>
<table
ref={ref}

View File

@@ -118,6 +118,12 @@
0 0;
transition-duration: 0.5s;
}
.card {
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.05);
border: 0 !important;
@apply bg-white rounded-xl;
}
}
.resizer {

View File

@@ -11,7 +11,7 @@
"js:codegen": "pnpm -r --filter sdk-web run build-for-openpanel",
"migrate": "pnpm -r --filter db run migrate",
"migrate:deploy": "pnpm -r --filter db run migrate:deploy",
"dev": "pnpm -r dev",
"dev": "pnpm -r --parallel testing",
"format": "pnpm -r format --cache --cache-location=\"node_modules/.cache/.prettiercache\"",
"format:fix": "pnpm -r format --write --cache --cache-location=\"node_modules/.cache/.prettiercache\"",
"lint": "pnpm -r lint",