add 30 min active user histogram

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-13 23:18:24 +01:00
parent 2572da3456
commit f32dc4711a
34 changed files with 570 additions and 457 deletions

View File

@@ -187,9 +187,7 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
setMetric(index);
}}
>
<Suspense fallback={<MetricCardLoading />}>
<Chart hideID {...report} />
</Suspense>
<Chart 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-chart-0 ring-chart-0',
@@ -204,14 +202,12 @@ export default function OverviewMetrics({ projectId }: OverviewMetricsProps) {
<div className="title">{selectedMetric.events[0]?.displayName}</div>
</WidgetHead>
<WidgetBody>
<Suspense fallback={<ChartLoading />}>
<Chart
key={selectedMetric.id}
hideID
{...selectedMetric}
chartType="linear"
/>
</Suspense>
<Chart
key={selectedMetric.id}
hideID
{...selectedMetric}
chartType="linear"
/>
</WidgetBody>
</Widget>
</>

View File

@@ -2,6 +2,7 @@ import PageLayout from '@/app/(app)/[organizationId]/[projectId]/page-layout';
import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewFilters } from '@/components/overview/overview-filters';
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import { OverviewShare } from '@/components/overview/overview-share';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
@@ -42,19 +43,22 @@ export default async function Page({
return (
<PageLayout title="Overview" organizationSlug={organizationId}>
<Sheet>
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFilterSheetTrigger />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />
<OverviewShare data={share} />
<StickyBelowHeader>
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFilterSheetTrigger />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />
<OverviewShare data={share} />
</div>
</div>
<OverviewFiltersButtons />
</StickyBelowHeader>
<div className="p-4 grid gap-4 grid-cols-6">
<div className="col-span-6 flex flex-wrap gap-2">
<OverviewFiltersButtons />
<div className="col-span-6">
<OverviewLiveHistogram projectId={projectId} />
</div>
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />

View File

@@ -1,9 +1,8 @@
'use client';
import { Suspense, useEffect } from 'react';
import { 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';
@@ -18,6 +17,7 @@ import {
import { ReportSidebar } from '@/components/report/sidebar/ReportSidebar';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch, useSelector } from '@/redux';
import type { IServiceReport } from '@/server/services/reports.service';
import { GanttChartSquareIcon } from 'lucide-react';
@@ -29,6 +29,7 @@ interface ReportEditorProps {
export default function ReportEditor({
report: initialReport,
}: ReportEditorProps) {
const { projectId } = useAppParams();
const dispatch = useDispatch();
const report = useSelector((state) => state.report);
@@ -72,11 +73,7 @@ export default function ReportEditor({
</div>
</StickyBelowHeader>
<div className="flex flex-col gap-4 p-4">
{report.ready && (
<Suspense fallback={<ChartLoading />}>
<Chart {...report} editMode />
</Suspense>
)}
{report.ready && <Chart {...report} projectId={projectId} editMode />}
</div>
<SheetContent className="!max-w-lg w-full" side="left">
<ReportSidebar />

View File

@@ -8,6 +8,7 @@ import { Logo } from '@/components/Logo';
import ServerLiveCounter from '@/components/overview/live-counter';
import { OverviewFilters } from '@/components/overview/overview-filters';
import { OverviewFiltersButtons } from '@/components/overview/overview-filters-buttons';
import { OverviewLiveHistogram } from '@/components/overview/overview-live-histogram';
import OverviewTopDevices from '@/components/overview/overview-top-devices';
import OverviewTopEvents from '@/components/overview/overview-top-events';
import OverviewTopGeo from '@/components/overview/overview-top-geo';
@@ -49,18 +50,21 @@ export default async function Page({ params: { id } }: PageProps) {
</div>
<div className="bg-white rounded-lg shadow ring-8 ring-blue-600/50">
<Sheet>
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFilterSheetTrigger />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />
<StickyBelowHeader>
<div className="p-4 flex gap-2 justify-between">
<div className="flex gap-2">
<OverviewReportRange />
<OverviewFilterSheetTrigger />
</div>
<div className="flex gap-2">
<ServerLiveCounter projectId={projectId} />
</div>
</div>
<OverviewFiltersButtons />
</StickyBelowHeader>
<div className="p-4 grid gap-4 grid-cols-6">
<div className="col-span-6 flex flex-wrap gap-2">
<OverviewFiltersButtons />
<div className="col-span-6">
<OverviewLiveHistogram projectId={projectId} />
</div>
<OverviewMetrics projectId={projectId} />
<OverviewTopSources projectId={projectId} />

View File

@@ -12,6 +12,8 @@ import dynamic from 'next/dynamic';
import useWebSocket from 'react-use-websocket';
import { toast } from 'sonner';
import { useOverviewOptions } from '../useOverviewOptions';
export interface LiveCounterProps {
data: number;
projectId: string;
@@ -25,6 +27,7 @@ const AnimatedNumbers = dynamic(() => import('react-animated-numbers'), {
const FIFTEEN_SECONDS = 1000 * 15;
export default function LiveCounter({ data = 0, projectId }: LiveCounterProps) {
const { setLiveHistogram } = useOverviewOptions();
const ws = String(process.env.NEXT_PUBLIC_API_URL)
.replace(/^https/, 'wss')
.replace(/^http/, 'ws');
@@ -52,8 +55,11 @@ export default function LiveCounter({ data = 0, projectId }: LiveCounterProps) {
return (
<Tooltip>
<TooltipTrigger>
<div className="border border-border rounded h-8 px-3 leading-none flex items-center font-medium gap-2">
<TooltipTrigger asChild>
<button
onClick={() => setLiveHistogram((p) => !p)}
className="border border-border rounded h-8 px-3 leading-none flex items-center font-medium gap-2"
>
<div className="relative">
<div
className={cn(
@@ -80,10 +86,11 @@ export default function LiveCounter({ data = 0, projectId }: LiveCounterProps) {
animateToNumber={counter}
locale="en"
/>
</div>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{counter} unique visitors last 5 minutes
<p>{counter} unique visitors last 5 minutes</p>
<p>Click to see activity for the last 30 minutes</p>
</TooltipContent>
</Tooltip>
);

View File

@@ -1,5 +1,6 @@
'use client';
import { cn } from '@/utils/cn';
import { X } from 'lucide-react';
import { Button } from '../ui/button';
@@ -7,8 +8,10 @@ import { useOverviewOptions } from './useOverviewOptions';
export function OverviewFiltersButtons() {
const options = useOverviewOptions();
const activeFilter = options.filters.length > 0;
return (
<>
<div className={cn('flex flex-wrap gap-2', activeFilter && 'px-4 pb-4')}>
{options.referrer && (
<Button
size="sm"
@@ -196,6 +199,6 @@ export function OverviewFiltersButtons() {
<strong>{options.osVersion}</strong>
</Button>
)}
</>
</div>
);
}

View File

@@ -0,0 +1,68 @@
'use client';
import type { IChartInput } from '@/types';
import { cn } from '@/utils/cn';
import { ChevronsUpDownIcon } from 'lucide-react';
import AnimateHeight from 'react-animate-height';
import { Chart } from '../report/chart';
import { Widget, WidgetBody, WidgetHead } from '../Widget';
import { useOverviewOptions } from './useOverviewOptions';
interface OverviewLiveHistogramProps {
projectId: string;
}
export function OverviewLiveHistogram({
projectId,
}: OverviewLiveHistogramProps) {
const { liveHistogram, setLiveHistogram } = useOverviewOptions();
const report: IChartInput = {
projectId,
events: [
{
segment: 'user',
filters: [
{
id: '1',
name: 'name',
operator: 'is',
value: ['screen_view', 'session_start'],
},
],
id: 'A',
name: '*',
displayName: 'Active users',
},
],
chartType: 'histogram',
interval: 'minute',
range: '30min',
name: '',
metric: 'sum',
breakdowns: [],
lineType: 'monotone',
previous: true,
};
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>
<Chart {...report} />
</WidgetBody>
</AnimateHeight>
</Widget>
);
}

View File

@@ -1,8 +1,6 @@
'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';
@@ -25,6 +23,7 @@ export default function OverviewTopDevices({
setBrowserVersion,
setOS,
setOSVersion,
setDevice,
} = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: {
@@ -187,31 +186,32 @@ export default function OverviewTopDevices({
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Suspense fallback={<ChartLoading />}>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'browser':
setWidget('browser_version');
setBrowser(item.name);
break;
case 'browser_version':
setBrowserVersion(item.name);
break;
case 'os':
setWidget('os_version');
setOS(item.name);
break;
case 'os_version':
setOSVersion(item.name);
break;
}
}}
/>
</Suspense>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'devices':
setDevice(item.name);
break;
case 'browser':
setWidget('browser_version');
setBrowser(item.name);
break;
case 'browser_version':
setBrowserVersion(item.name);
break;
case 'os':
setWidget('os_version');
setOS(item.name);
break;
case 'os_version':
setOSVersion(item.name);
break;
}
}}
/>
</WidgetBody>
</Widget>
</>

View File

@@ -74,9 +74,7 @@ export default function OverviewTopEvents({
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Suspense fallback={<ChartLoading />}>
<Chart hideID {...widget.chart} previous={false} />
</Suspense>
<Chart hideID {...widget.chart} previous={false} />
</WidgetBody>
</Widget>
</>

View File

@@ -149,28 +149,26 @@ export default function OverviewTopGeo({ projectId }: OverviewTopGeoProps) {
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<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>
<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;
}
}}
/>
</WidgetBody>
</Widget>
</>

View File

@@ -120,16 +120,14 @@ export default function OverviewTopPages({ projectId }: OverviewTopPagesProps) {
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Suspense fallback={<ChartLoading />}>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
setPage(item.name);
}}
/>
</Suspense>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
setPage(item.name);
}}
/>
</WidgetBody>
</Widget>
</>

View File

@@ -275,43 +275,41 @@ export default function OverviewTopSources({
</WidgetButtons>
</WidgetHead>
<WidgetBody>
<Suspense fallback={<ChartLoading />}>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'all':
setReferrerName(item.name);
setWidget('domain');
break;
case 'domain':
setReferrer(item.name);
break;
case 'type':
setReferrerType(item.name);
setWidget('domain');
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>
<Chart
hideID
{...widget.chart}
previous={false}
onClick={(item) => {
switch (widget.key) {
case 'all':
setReferrerName(item.name);
setWidget('domain');
break;
case 'domain':
setReferrer(item.name);
break;
case 'type':
setReferrerType(item.name);
setWidget('domain');
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;
}
}}
/>
</WidgetBody>
</Widget>
</>

View File

@@ -1,9 +1,8 @@
'use client';
import { Children, useCallback, useEffect, useRef, useState } from 'react';
import { Children, useEffect, useRef, useState } from 'react';
import { useThrottle } from '@/hooks/useThrottle';
import { cn } from '@/utils/cn';
import throttle from 'lodash.throttle';
import { ChevronsUpDownIcon } from 'lucide-react';
import { last } from 'ramda';

View File

@@ -107,6 +107,12 @@ export function useOverviewOptions() {
parseAsString.withOptions(nuqsOptions)
);
// Toggles
const [liveHistogram, setLiveHistogram] = useQueryState(
'live',
parseAsBoolean.withDefault(false).withOptions(nuqsOptions)
);
const filters = useMemo(() => {
const filters: IChartInput['events'][number]['filters'] = [];
@@ -337,5 +343,9 @@ export function useOverviewOptions() {
setOS,
osVersion,
setOSVersion,
// Toggles
liveHistogram,
setLiveHistogram,
};
}

View File

@@ -3,6 +3,7 @@
import {
createContext,
memo,
Suspense,
useContext,
useEffect,
useMemo,
@@ -12,6 +13,7 @@ import type { IChartSerie } from '@/server/api/routers/chart';
import type { IChartInput } from '@/types';
import { ChartLoading } from './ChartLoading';
import { MetricCardLoading } from './MetricCard';
export interface ChartContextType extends IChartInput {
editMode?: boolean;
@@ -47,10 +49,10 @@ export function ChartProvider({
<ChartContext.Provider
value={useMemo(
() => ({
...props,
editMode: editMode ?? false,
previous: previous ?? false,
hideID: hideID ?? false,
...props,
}),
[editMode, previous, hideID, props]
)}
@@ -64,20 +66,34 @@ export function withChartProivder<ComponentProps>(
WrappedComponent: React.FC<ComponentProps>
) {
const WithChartProvider = (props: ComponentProps & ChartContextType) => {
const [mounted, setMounted] = useState(props.chartType === 'metric');
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return <ChartLoading />;
return props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading />
);
}
return (
<ChartProvider {...props}>
<WrappedComponent {...props} />
</ChartProvider>
<Suspense
fallback={
props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading />
)
}
>
<ChartProvider {...props}>
<WrappedComponent {...props} />
</ChartProvider>
</Suspense>
);
};

View File

@@ -23,13 +23,11 @@ export function LazyChart(props: ReportChartProps & ChartContextType) {
return (
<div ref={ref}>
<Suspense fallback={<ChartLoading />}>
{once.current || inViewport ? (
<Chart {...props} editMode={false} />
) : (
<ChartLoading />
)}
</Suspense>
{once.current || inViewport ? (
<Chart {...props} editMode={false} />
) : (
<ChartLoading />
)}
</div>
);
}

View File

@@ -20,6 +20,7 @@ import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportAreaChartProps {
data: IChartData;
@@ -39,83 +40,72 @@ export function ReportAreaChart({
return (
<>
<div
className={cn(
'max-sm:-mx-3 aspect-video w-full max-h-[300px] min-h-[200px]',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) => (
<AreaChart
width={width}
height={Math.min(Math.max(width * 0.5625, 250), 300)}
data={rechartData}
>
<Tooltip content={<ReportChartTooltip />} />
<XAxis
axisLine={false}
fontSize={12}
dataKey="date"
tickFormatter={(m: string) => formatDate(m)}
tickLine={false}
allowDuplicatedCategory={false}
/>
<YAxis
width={getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
<ResponsiveContainer>
{({ width, height }) => (
<AreaChart width={width} height={height} data={rechartData}>
<Tooltip content={<ReportChartTooltip />} />
<XAxis
axisLine={false}
fontSize={12}
dataKey="date"
tickFormatter={(m: string) => formatDate(m)}
tickLine={false}
allowDuplicatedCategory={false}
/>
<YAxis
width={getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<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
strokeDasharray="3 3"
horizontal={true}
vertical={false}
/>
</AreaChart>
)}
</AutoSizer>
</div>
{series.map((serie) => {
const color = getChartColor(serie.index);
return (
<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
strokeDasharray="3 3"
horizontal={true}
vertical={false}
/>
</AreaChart>
)}
</ResponsiveContainer>
{editMode && (
<ReportTable
data={data}

View File

@@ -19,9 +19,8 @@ export function ReportChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
const { unit } = useChartContext();
const { unit, interval } = useChartContext();
const getLabel = useMappings();
const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
if (!active || !payload) {

View File

@@ -13,6 +13,7 @@ import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportHistogramChartProps {
data: IChartData;
@@ -43,61 +44,52 @@ export function ReportHistogramChart({
return (
<>
<div
className={cn(
'max-sm:-mx-3 aspect-video w-full max-h-[300px] min-h-[200px]',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) => (
<BarChart
width={width}
height={Math.min(Math.max(width * 0.5625, 250), 300)}
data={rechartData}
>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
<XAxis
fontSize={12}
dataKey="date"
tickFormatter={formatDate}
tickLine={false}
axisLine={false}
/>
<YAxis
fontSize={12}
axisLine={false}
tickLine={false}
width={getYAxisWidth(data.metrics.max)}
/>
{series.map((serie) => {
return (
<React.Fragment key={serie.name}>
{previous && (
<Bar
key={`${serie.name}:prev`}
name={`${serie.name}:prev`}
dataKey={`${serie.index}:prev:count`}
fill={getChartColor(serie.index)}
fillOpacity={0.2}
radius={8}
/>
)}
<ResponsiveContainer>
{({ width, height }) => (
<BarChart width={width} height={height} data={rechartData}>
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
<XAxis
fontSize={12}
dataKey="date"
tickFormatter={formatDate}
tickLine={false}
axisLine={false}
/>
<YAxis
fontSize={12}
axisLine={false}
tickLine={false}
width={getYAxisWidth(data.metrics.max)}
allowDecimals={false}
domain={[0, data.metrics.max]}
/>
{series.map((serie) => {
return (
<React.Fragment key={serie.name}>
{previous && (
<Bar
key={serie.name}
name={serie.name}
dataKey={`${serie.index}:count`}
key={`${serie.name}:prev`}
name={`${serie.name}:prev`}
dataKey={`${serie.index}:prev:count`}
fill={getChartColor(serie.index)}
fillOpacity={0.2}
radius={8}
/>
</React.Fragment>
);
})}
</BarChart>
)}
</AutoSizer>
</div>
)}
<Bar
key={serie.name}
name={serie.name}
dataKey={`${serie.index}:count`}
fill={getChartColor(serie.index)}
radius={8}
/>
</React.Fragment>
);
})}
</BarChart>
)}
</ResponsiveContainer>
{editMode && (
<ReportTable
data={data}

View File

@@ -22,6 +22,7 @@ import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
import { ResponsiveContainer } from './ResponsiveContainer';
interface ReportLineChartProps {
data: IChartData;
@@ -41,72 +42,61 @@ export function ReportLineChart({
return (
<>
<div
className={cn(
'max-sm:-mx-3 aspect-video w-full max-h-[300px] min-h-[200px]',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) => (
<LineChart
width={width}
height={Math.min(Math.max(width * 0.5625, 250), 300)}
data={rechartData}
>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
/>
<YAxis
width={getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
<Tooltip content={<ReportChartTooltip />} />
<XAxis
axisLine={false}
fontSize={12}
dataKey="date"
tickFormatter={(m: string) => formatDate(m)}
tickLine={false}
allowDuplicatedCategory={false}
/>
{series.map((serie) => {
return (
<React.Fragment key={serie.name}>
<ResponsiveContainer>
{({ width, height }) => (
<LineChart width={width} height={height} data={rechartData}>
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
/>
<YAxis
width={getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
allowDecimals={false}
/>
<Tooltip content={<ReportChartTooltip />} />
<XAxis
axisLine={false}
fontSize={12}
dataKey="date"
tickFormatter={(m: string) => formatDate(m)}
tickLine={false}
allowDuplicatedCategory={false}
/>
{series.map((serie) => {
return (
<React.Fragment key={serie.name}>
<Line
type={lineType}
key={serie.name}
name={serie.name}
isAnimationActive={true}
strokeWidth={2}
dataKey={`${serie.index}:count`}
stroke={getChartColor(serie.index)}
/>
{previous && (
<Line
type={lineType}
key={serie.name}
name={serie.name}
key={`${serie.name}:prev`}
name={`${serie.name}:prev`}
isAnimationActive={true}
strokeWidth={2}
dataKey={`${serie.index}:count`}
strokeWidth={1}
dot={false}
strokeDasharray={'6 6'}
dataKey={`${serie.index}:prev:count`}
stroke={getChartColor(serie.index)}
/>
{previous && (
<Line
type={lineType}
key={`${serie.name}:prev`}
name={`${serie.name}:prev`}
isAnimationActive={true}
strokeWidth={1}
dot={false}
strokeDasharray={'6 6'}
dataKey={`${serie.index}:prev:count`}
stroke={getChartColor(serie.index)}
/>
)}
</React.Fragment>
);
})}
</LineChart>
)}
</AutoSizer>
</div>
)}
</React.Fragment>
);
})}
</LineChart>
)}
</ResponsiveContainer>
{editMode && (
<ReportTable
data={data}

View File

@@ -0,0 +1,39 @@
import { cn } from '@/utils/cn';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useChartContext } from './ChartProvider';
interface ResponsiveContainerProps {
children: (props: { width: number; height: number }) => React.ReactNode;
}
export function ResponsiveContainer({ children }: ResponsiveContainerProps) {
const { editMode } = useChartContext();
const maxHeight = 300;
const minHeight = 200;
return (
<div
style={{
maxHeight,
minHeight,
}}
className={cn(
'max-sm:-mx-3 aspect-video w-full',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) =>
children({
width,
height: Math.min(
Math.max(width * 0.5625, minHeight),
// we add p-4 (16px) padding in edit mode
editMode ? maxHeight - 16 : maxHeight
),
})
}
</AutoSizer>
</div>
);
}

View File

@@ -60,9 +60,9 @@ export function SerieIcon({ name, ...props }: SerieIconProps) {
)) as LucideIcon;
}
return (
return Icon ? (
<div className="w-4 h-4 flex-shrink-0 relative [&_a]:!w-4 [&_a]:!h-4 [&_svg]:!rounded">
{Icon ? <Icon size={16} {...props} /> : null}
<Icon size={16} {...props} />
</div>
);
) : null;
}

View File

@@ -20,80 +20,78 @@ export type ReportChartProps = IChartInput & {
initialData?: RouterOutputs['chart']['chart'];
};
export const Chart = memo(
withChartProivder(function Chart({
interval,
events,
breakdowns,
chartType,
name,
range,
lineType,
previous,
formula,
unit,
metric,
projectId,
}: ReportChartProps) {
const [data] = api.chart.chart.useSuspenseQuery(
{
// dont send lineType since it does not need to be sent
lineType: 'monotone',
interval,
chartType,
events,
breakdowns,
name,
range,
startDate: null,
endDate: null,
projectId,
previous,
formula,
unit,
metric,
},
{
keepPreviousData: true,
}
export const Chart = withChartProivder(function Chart({
interval,
events,
breakdowns,
chartType,
name,
range,
lineType,
previous,
formula,
unit,
metric,
projectId,
}: ReportChartProps) {
const [data] = api.chart.chart.useSuspenseQuery(
{
// dont send lineType since it does not need to be sent
lineType: 'monotone',
interval,
chartType,
events,
breakdowns,
name,
range,
startDate: null,
endDate: null,
projectId,
previous,
formula,
unit,
metric,
},
{
keepPreviousData: true,
}
);
if (data.series.length === 0) {
return <ChartEmpty />;
}
if (chartType === 'map') {
return <ReportMapChart data={data} />;
}
if (chartType === 'histogram') {
return <ReportHistogramChart interval={interval} data={data} />;
}
if (chartType === 'bar') {
return <ReportBarChart data={data} />;
}
if (chartType === 'metric') {
return <ReportMetricChart data={data} />;
}
if (chartType === 'pie') {
return <ReportPieChart data={data} />;
}
if (chartType === 'linear') {
return (
<ReportLineChart lineType={lineType} interval={interval} data={data} />
);
}
if (data.series.length === 0) {
return <ChartEmpty />;
}
if (chartType === 'area') {
return (
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
);
}
if (chartType === 'map') {
return <ReportMapChart data={data} />;
}
if (chartType === 'histogram') {
return <ReportHistogramChart interval={interval} data={data} />;
}
if (chartType === 'bar') {
return <ReportBarChart data={data} />;
}
if (chartType === 'metric') {
return <ReportMetricChart data={data} />;
}
if (chartType === 'pie') {
return <ReportPieChart data={data} />;
}
if (chartType === 'linear') {
return (
<ReportLineChart lineType={lineType} interval={interval} data={data} />
);
}
if (chartType === 'area') {
return (
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
);
}
return <p>Unknown chart type</p>;
})
);
return <p>Unknown chart type</p>;
});

View File

@@ -1,11 +1,11 @@
import { api } from '@/app/_trpc/client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch } from '@/redux';
import type { IChartEvent } from '@/types';
import { cn } from '@/utils/cn';
import { DatabaseIcon } from 'lucide-react';
import { useChartContext } from '../chart/ChartProvider';
import { changeEvent } from '../reportSlice';
interface EventPropertiesComboboxProps {
@@ -16,7 +16,7 @@ export function EventPropertiesCombobox({
event,
}: EventPropertiesComboboxProps) {
const dispatch = useDispatch();
const { projectId } = useChartContext();
const { projectId } = useAppParams();
const query = api.chart.properties.useQuery(
{

View File

@@ -3,17 +3,17 @@
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch, useSelector } from '@/redux';
import type { IChartBreakdown } from '@/types';
import { SplitIcon } from 'lucide-react';
import { useChartContext } from '../chart/ChartProvider';
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
import { ReportBreakdownMore } from './ReportBreakdownMore';
import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportBreakdowns() {
const { projectId } = useChartContext();
const { projectId } = useAppParams();
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery({

View File

@@ -6,12 +6,12 @@ import { Dropdown } from '@/components/Dropdown';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { useAppParams } from '@/hooks/useAppParams';
import { useDebounceFn } from '@/hooks/useDebounceFn';
import { useDispatch, useSelector } from '@/redux';
import type { IChartEvent } from '@/types';
import { GanttChart, GanttChartIcon, Users } from 'lucide-react';
import { useChartContext } from '../chart/ChartProvider';
import {
addEvent,
changeEvent,
@@ -28,7 +28,8 @@ export function ReportEvents() {
const previous = useSelector((state) => state.report.previous);
const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch();
const { projectId } = useChartContext();
const { projectId } = useAppParams();
const eventsQuery = api.chart.events.useQuery({
projectId,
});

View File

@@ -4,6 +4,7 @@ import { Dropdown } from '@/components/Dropdown';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { RenderDots } from '@/components/ui/RenderDots';
import { useAppParams } from '@/hooks/useAppParams';
import { useMappings } from '@/hooks/useMappings';
import { useDispatch } from '@/redux';
import type {
@@ -14,7 +15,6 @@ import type {
import { operators } from '@/utils/constants';
import { SlidersHorizontal, Trash } from 'lucide-react';
import { useChartContext } from '../../chart/ChartProvider';
import { changeEvent } from '../../reportSlice';
interface FilterProps {
@@ -23,7 +23,7 @@ interface FilterProps {
}
export function FilterItem({ filter, event }: FilterProps) {
const { projectId } = useChartContext();
const { projectId } = useAppParams();
const getLabel = useMappings();
const dispatch = useDispatch();
const potentialValues = api.chart.values.useQuery({

View File

@@ -1,10 +1,10 @@
import { api } from '@/app/_trpc/client';
import { Combobox } from '@/components/ui/combobox';
import { useAppParams } from '@/hooks/useAppParams';
import { useDispatch } from '@/redux';
import type { IChartEvent } from '@/types';
import { FilterIcon } from 'lucide-react';
import { useChartContext } from '../../chart/ChartProvider';
import { changeEvent } from '../../reportSlice';
interface FiltersComboboxProps {
@@ -13,7 +13,7 @@ interface FiltersComboboxProps {
export function FiltersCombobox({ event }: FiltersComboboxProps) {
const dispatch = useDispatch();
const { projectId } = useChartContext();
const { projectId } = useAppParams();
const query = api.chart.properties.useQuery(
{

View File

@@ -1,6 +1,6 @@
'use client';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
export type IVisibleSeries = ReturnType<typeof useVisibleSeries>['series'];
@@ -10,6 +10,12 @@ export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
);
useEffect(() => {
setVisibleSeries(
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
);
}, [data, max]);
return useMemo(() => {
return {
series: data.series

View File

@@ -4,13 +4,9 @@ import { authMiddleware } from '@clerk/nextjs';
// Please edit this to allow other routes to be public as needed.
// See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware
export default authMiddleware({
publicRoutes: [
'/share/overview/:id',
'/api/trpc/chart.chart',
'/api/trpc/chart.values',
],
publicRoutes: ['/share/overview/:id', '/api/trpc(.*)'],
});
export const config = {
matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api|trpc)(.*)'],
matcher: ['/((?!.+\\.[\\w]+$|_next).*)', '/', '/(api)(.*)'],
};

View File

@@ -10,7 +10,7 @@ import { round } from '@/utils/math';
import * as mathjs from 'mathjs';
import { sort } from 'ramda';
import { chQuery } from '@mixan/db';
import { chQuery, convertClickhouseDateToJs } from '@mixan/db';
export type GetChartDataResult = Awaited<ReturnType<typeof getChartData>>;
export interface ResultItem {
@@ -73,7 +73,7 @@ function fillEmptySpotsInTimeline(
const getMinute = (date: Date) => date.getUTCMinutes();
const item = items.find((item) => {
const date = new Date(item.date);
const date = convertClickhouseDateToJs(item.date);
if (interval === 'month') {
return (

View File

@@ -10,7 +10,7 @@ import { zChartInput } from '@/utils/validation';
import { flatten, map, pipe, prop, sort, uniq } from 'ramda';
import { z } from 'zod';
import { chQuery } from '@mixan/db';
import { chQuery, createSqlBuilder } from '@mixan/db';
import { getChartData, withFormula } from './chart.helpers';
@@ -117,16 +117,20 @@ export const chartRouter = createTRPCRouter({
})
)
.query(async ({ input: { event, property, projectId } }) => {
const sql = property.startsWith('properties.')
? `SELECT distinct mapValues(mapExtractKeyLike(properties, '${property
.replace(/^properties\./, '')
.replace(
'.*.',
'.%.'
)}')) as values from events where name = '${event}' AND project_id = '${projectId}';`
: `SELECT ${property} as values from events where name = '${event}' AND project_id = '${projectId}';`;
const { sb, getSql } = createSqlBuilder();
sb.where.project_id = `project_id = '${projectId}'`;
if (event !== '*') {
sb.where.event = `name = '${event}'`;
}
if (property.startsWith('properties.')) {
sb.select.values = `distinct mapValues(mapExtractKeyLike(properties, '${property
.replace(/^properties\./, '')
.replace('.*.', '.%.')}')) as values`;
} else {
sb.select.values = `${property} as values`;
}
const events = await chQuery<{ values: string[] }>(sql);
const events = await chQuery<{ values: string[] }>(getSql());
const values = pipe(
(data: typeof events) => map(prop('values'), data),