move sdk packages to its own folder and rename api & dashboard

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-11 13:15:44 +01:00
parent 1ca95442b9
commit 6d4f9010d4
318 changed files with 350 additions and 351 deletions

View File

@@ -0,0 +1,106 @@
'use client';
import { api } from '@/app/_trpc/client';
import type { IChartInput } from '@mixan/validation';
import { ChartEmpty } from './ChartEmpty';
import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart';
import { ReportHistogramChart } from './ReportHistogramChart';
import { ReportLineChart } from './ReportLineChart';
import { ReportMapChart } from './ReportMapChart';
import { ReportMetricChart } from './ReportMetricChart';
import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartInput;
export function Chart({
interval,
events,
breakdowns,
chartType,
name,
range,
lineType,
previous,
formula,
unit,
metric,
projectId,
startDate,
endDate,
}: ReportChartProps) {
const [references] = api.reference.getChartReferences.useSuspenseQuery({
projectId,
startDate,
endDate,
range,
});
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,
endDate,
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}
references={references}
/>
);
}
if (chartType === 'area') {
return (
<ReportAreaChart lineType={lineType} interval={interval} data={data} />
);
}
return <p>Unknown chart type</p>;
}

View File

@@ -0,0 +1,32 @@
import airplane from '@/lottie/airplane.json';
import ballon from '@/lottie/ballon.json';
import noData from '@/lottie/no-data.json';
import { cn } from '@/utils/cn';
import type { LottieComponentProps } from 'lottie-react';
import Lottie from 'lottie-react';
const animations = {
airplane,
ballon,
noData,
};
type Animations = keyof typeof animations;
export const ChartAnimation = ({
name,
...props
}: Omit<LottieComponentProps, 'animationData'> & {
name: Animations;
}) => <Lottie animationData={animations[name]} loop={true} {...props} />;
export const ChartAnimationContainer = (
props: React.ButtonHTMLAttributes<HTMLDivElement>
) => (
<div
{...props}
className={cn(
'border border-border rounded-md p-8 bg-white',
props.className
)}
/>
);

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-[300px] min-h-[200px] 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-[300px] min-h-[200px]',
className
)}
/>
);
}

View File

@@ -0,0 +1,110 @@
'use client';
import {
createContext,
memo,
Suspense,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import type { IChartSerie } from '@/server/api/routers/chart';
import type { IChartInput } from '@mixan/validation';
import { ChartLoading } from './ChartLoading';
import { MetricCardLoading } from './MetricCard';
export interface ChartContextType extends IChartInput {
editMode?: boolean;
hideID?: boolean;
onClick?: (item: IChartSerie) => void;
}
type ChartProviderProps = {
children: React.ReactNode;
} & ChartContextType;
const ChartContext = createContext<ChartContextType | null>({
events: [],
breakdowns: [],
chartType: 'linear',
lineType: 'monotone',
interval: 'day',
name: '',
range: '7d',
metric: 'sum',
previous: false,
projectId: '',
});
export function ChartProvider({
children,
editMode,
previous,
hideID,
...props
}: ChartProviderProps) {
return (
<ChartContext.Provider
value={useMemo(
() => ({
...props,
editMode: editMode ?? false,
previous: previous ?? false,
hideID: hideID ?? false,
}),
[editMode, previous, hideID, props]
)}
>
{children}
</ChartContext.Provider>
);
}
export function withChartProivder<ComponentProps>(
WrappedComponent: React.FC<ComponentProps>
) {
const WithChartProvider = (props: ComponentProps & ChartContextType) => {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading />
);
}
return (
<Suspense
fallback={
props.chartType === 'metric' ? (
<MetricCardLoading />
) : (
<ChartLoading />
)
}
>
<ChartProvider {...props}>
<WrappedComponent {...props} />
</ChartProvider>
</Suspense>
);
};
WithChartProvider.displayName = `WithChartProvider(${
WrappedComponent.displayName ?? WrappedComponent.name ?? 'Component'
})`;
return memo(WithChartProvider);
}
export function useChartContext() {
return useContext(ChartContext)!;
}

View File

@@ -0,0 +1,33 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';
import type { ReportChartProps } from '.';
import { ChartSwitch } from '.';
import { ChartLoading } from './ChartLoading';
import type { ChartContextType } from './ChartProvider';
export function LazyChart(props: ReportChartProps & ChartContextType) {
const ref = useRef<HTMLDivElement>(null);
const once = useRef(false);
const { inViewport } = useInViewport(ref, undefined, {
disconnectOnLeave: true,
});
useEffect(() => {
if (inViewport) {
once.current = true;
}
}, [inViewport]);
return (
<div ref={ref}>
{once.current || inViewport ? (
<ChartSwitch {...props} editMode={false} />
) : (
<ChartLoading />
)}
</div>
);
}

View File

@@ -0,0 +1,124 @@
'use client';
import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { fancyMinutes, useNumber } from '@/hooks/useNumerFormatter';
import { theme } from '@/utils/theme';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import type { IChartMetric } from '@mixan/validation';
import {
getDiffIndicator,
PreviousDiffIndicatorText,
} from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
interface MetricCardProps {
serie: IChartData['series'][number];
color?: string;
metric: IChartMetric;
unit?: string;
}
export function MetricCard({
serie,
color: _color,
metric,
unit,
}: MetricCardProps) {
const { previousIndicatorInverted } = useChartContext();
const number = useNumber();
const renderValue = (value: number, unitClassName?: string) => {
if (unit === 'min') {
return <>{fancyMinutes(value)}</>;
}
return (
<>
{number.short(value)}
{unit && <span className={unitClassName}>{unit}</span>}
</>
);
};
const previous = serie.metrics.previous[metric];
const graphColors = getDiffIndicator(
previousIndicatorInverted,
previous?.state,
'green',
'red',
'blue'
);
return (
<div
className="group relative card p-4 overflow-hidden h-24"
key={serie.name}
>
<div className="absolute inset-0 -left-1 -right-1 z-0 opacity-20 transition-opacity duration-300 group-hover:opacity-50 rounded-md">
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={height / 4}
data={serie.data}
style={{ marginTop: (height / 4) * 3 }}
>
<Area
dataKey="count"
type="monotone"
fill={`transparent`}
fillOpacity={1}
stroke={graphColors}
strokeWidth={2}
/>
</AreaChart>
)}
</AutoSizer>
</div>
<div className="relative">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 font-medium text-left min-w-0">
<ColorSquare>{serie.event.id}</ColorSquare>
<span className="text-ellipsis overflow-hidden whitespace-nowrap">
{serie.name || serie.event.displayName || serie.event.name}
</span>
</div>
{/* <PreviousDiffIndicator {...serie.metrics.previous[metric]} /> */}
</div>
<div className="flex justify-between items-end mt-2">
<div className="text-2xl font-bold text-ellipsis overflow-hidden whitespace-nowrap">
{renderValue(serie.metrics[metric], 'ml-1 font-light text-xl')}
</div>
<PreviousDiffIndicatorText
{...serie.metrics.previous[metric]}
className="font-medium text-xs mb-0.5"
/>
</div>
</div>
</div>
);
}
export function MetricCardEmpty() {
return (
<div className="card p-4 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 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

@@ -0,0 +1,120 @@
import React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { IChartLineType, IInterval } from '@mixan/validation';
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;
interval: IInterval;
lineType: IChartLineType;
}
export function ReportAreaChart({
lineType,
interval,
data,
}: ReportAreaChartProps) {
const { editMode } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const formatDate = useFormatDateInterval(interval);
const rechartData = useRechartDataModel(series);
const number = useNumber();
return (
<>
<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}
tickFormatter={number.short}
/>
{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}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -0,0 +1,134 @@
'use client';
import { useMemo } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { Progress } from '@/components/ui/progress';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import { NOT_SET_VALUE } from '@mixan/constants';
import { PreviousDiffIndicatorText } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
import { SerieIcon } from './SerieIcon';
interface ReportBarChartProps {
data: IChartData;
}
export function ReportBarChart({ data }: ReportBarChartProps) {
const { editMode, metric, onClick } = useChartContext();
const number = useNumber();
const series = useMemo(
() => (editMode ? data.series : data.series.slice(0, 10)),
[data, editMode]
);
const maxCount = Math.max(...series.map((serie) => serie.metrics[metric]));
return (
<div
className={cn(
'flex flex-col w-full text-xs -mx-2',
editMode && 'text-base card p-4'
)}
>
{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 rounded overflow-hidden',
'[&_[role=progressbar]]:even:bg-white [&_[role=progressbar]]:shadow-sm',
isClickable && 'cursor-pointer hover:!bg-slate-100'
)}
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
>
<div className="flex-1 break-all flex items-center gap-2 font-medium">
<SerieIcon name={serie.name} />
{serie.name}
</div>
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">
<PreviousDiffIndicatorText
{...serie.metrics.previous[metric]}
className="text-xs font-medium"
/>
{serie.metrics.previous[metric]?.value}
<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

@@ -0,0 +1,89 @@
import React from 'react';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useNumber } from '@/hooks/useNumerFormatter';
import type { IRechartPayloadItem } from '@/hooks/useRechartDataModel';
import type { IToolTipProps } from '@/types';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
import { useChartContext } from './ChartProvider';
type ReportLineChartTooltipProps = IToolTipProps<{
value: number;
dataKey: string;
payload: Record<string, unknown>;
}>;
export function ReportChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
const { unit, interval } = useChartContext();
const getLabel = useMappings();
const formatDate = useFormatDateInterval(interval);
const number = useNumber();
if (!active || !payload) {
return null;
}
if (!payload.length) {
return null;
}
const limit = 3;
const sorted = payload
.slice(0)
.filter((item) => !item.dataKey.includes(':prev:count'))
.sort((a, b) => b.value - a.value);
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
return (
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl min-w-[180px]">
{visible.map((item, index) => {
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
const data = (
item.dataKey.includes(':')
? // @ts-expect-error
payload[`${item.dataKey.split(':')[0]}:payload`]
: payload
) as IRechartPayloadItem;
return (
<React.Fragment key={data.label}>
{index === 0 && data.date && (
<div className="flex justify-between gap-8">
<div>{formatDate(new Date(data.date))}</div>
</div>
)}
<div className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: data.color }}
/>
<div className="flex flex-col flex-1">
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{getLabel(data.label)}
</div>
<div className="flex justify-between gap-8">
<div>{number.formatWithUnit(data.count, unit)}</div>
<div className="flex gap-1">
<PreviousDiffIndicator {...data.previous}>
{!!data.previous &&
`(${number.formatWithUnit(data.previous.value, unit)})`}
</PreviousDiffIndicator>
</div>
</div>
</div>
</div>
</React.Fragment>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground">and {hidden.length} more...</div>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
import React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { getChartColor, theme } from '@/utils/theme';
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
import type { IInterval } from '@mixan/validation';
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;
interval: IInterval;
}
function BarHover({ x, y, width, height, top, left, right, bottom }: any) {
const bg = theme?.colors?.slate?.['200'] as string;
return (
<rect
{...{ x, y, width, height, top, left, right, bottom }}
rx="8"
fill={bg}
fillOpacity={0.5}
/>
);
}
export function ReportHistogramChart({
interval,
data,
}: ReportHistogramChartProps) {
const { editMode, previous } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
const number = useNumber();
return (
<>
<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]}
tickFormatter={number.short}
/>
{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}
/>
)}
<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}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -0,0 +1,133 @@
'use client';
import React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { getChartColor } from '@/utils/theme';
import {
CartesianGrid,
Line,
LineChart,
ReferenceLine,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { IServiceReference } from '@mixan/db';
import type { IChartLineType, IInterval } from '@mixan/validation';
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;
references: IServiceReference[];
interval: IInterval;
lineType: IChartLineType;
}
export function ReportLineChart({
lineType,
interval,
data,
references,
}: ReportLineChartProps) {
const { editMode, previous } = useChartContext();
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(series);
const number = useNumber();
console.log(references.map((ref) => ref.createdAt.getTime()));
return (
<>
<ResponsiveContainer>
{({ width, height }) => (
<LineChart width={width} height={height} data={rechartData}>
{references.map((ref) => (
<ReferenceLine
key={ref.id}
x={ref.date.getTime()}
stroke={'#94a3b8'}
strokeDasharray={'3 3'}
label={{
value: ref.title,
position: 'centerTop',
fill: '#334155',
fontSize: 12,
}}
fontSize={10}
/>
))}
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
/>
<YAxis
width={getYAxisWidth(data.metrics.max)}
fontSize={12}
axisLine={false}
tickLine={false}
allowDecimals={false}
tickFormatter={number.short}
/>
<Tooltip content={<ReportChartTooltip />} />
<XAxis
axisLine={false}
fontSize={12}
dataKey="timestamp"
scale="utc"
domain={['dataMin', 'dataMax']}
tickFormatter={(m: string) => formatDate(new Date(m))}
type="number"
tickLine={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}: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>
)}
</ResponsiveContainer>
{editMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -0,0 +1,40 @@
import { useMemo } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { theme } from '@/utils/theme';
import WorldMap from 'react-svg-worldmap';
import AutoSizer from 'react-virtualized-auto-sizer';
import { useChartContext } from './ChartProvider';
interface ReportMapChartProps {
data: IChartData;
}
export function ReportMapChart({ data }: ReportMapChartProps) {
const { metric, unit } = useChartContext();
const { series } = useVisibleSeries(data, 100);
const mapData = useMemo(
() =>
series.map((s) => ({
country: s.name.toLowerCase(),
value: s.metrics[metric],
})),
[series, metric]
);
return (
<AutoSizer disableHeight>
{({ width }) => (
<WorldMap
size={width}
data={mapData}
color={theme.colors['chart-0']}
borderColor={'#103A96'}
value-suffix={unit}
/>
)}
</AutoSizer>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import type { IChartData } from '@/app/_trpc/client';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { useChartContext } from './ChartProvider';
import { MetricCard } from './MetricCard';
interface ReportMetricChartProps {
data: IChartData;
}
export function ReportMetricChart({ data }: ReportMetricChartProps) {
const { editMode, metric, unit } = useChartContext();
const { series } = useVisibleSeries(data, editMode ? undefined : 2);
return (
<div
className={cn(
'grid grid-cols-1 gap-4',
editMode && 'md:grid-cols-2 lg:grid-cols-3'
)}
>
{series.map((serie) => {
return (
<MetricCard
key={serie.name}
serie={serie}
metric={metric}
unit={unit}
/>
);
})}
</div>
);
}

View File

@@ -0,0 +1,129 @@
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { round } from '@/utils/math';
import { getChartColor } from '@/utils/theme';
import { truncate } from '@/utils/truncate';
import { Cell, Pie, PieChart, Tooltip } from 'recharts';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
interface ReportPieChartProps {
data: IChartData;
}
export function ReportPieChart({ data }: ReportPieChartProps) {
const { editMode } = useChartContext();
const { series, setVisibleSeries } = useVisibleSeries(data);
const sum = series.reduce((acc, serie) => acc + serie.metrics.sum, 0);
const pieData = series.map((serie) => ({
id: serie.name,
color: getChartColor(serie.index),
index: serie.index,
label: serie.name,
count: serie.metrics.sum,
percent: serie.metrics.sum / sum,
}));
return (
<>
<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);
return (
<PieChart width={width} height={height}>
<Tooltip content={<ReportChartTooltip />} />
<Pie
dataKey={'count'}
data={pieData}
innerRadius={height / 4}
outerRadius={height / 2.5}
isAnimationActive={true}
label={renderLabel}
>
{pieData.map((item) => {
return (
<Cell
key={item.id}
strokeWidth={2}
stroke={item.color}
fill={item.color}
/>
);
})}
</Pie>
</PieChart>
);
}}
</AutoSizer>
</div>
{editMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}
const renderLabel = ({
cx,
cy,
midAngle,
innerRadius,
outerRadius,
fill,
payload,
}: {
cx: number;
cy: number;
midAngle: number;
innerRadius: number;
outerRadius: number;
fill: string;
payload: { label: string; percent: number };
}) => {
const RADIAN = Math.PI / 180;
const radius = 25 + innerRadius + (outerRadius - innerRadius);
const radiusProcent = innerRadius + (outerRadius - innerRadius) * 0.5;
const xProcent = cx + radiusProcent * Math.cos(-midAngle * RADIAN);
const yProcent = cy + radiusProcent * Math.sin(-midAngle * RADIAN);
const x = cx + radius * Math.cos(-midAngle * RADIAN);
const y = cy + radius * Math.sin(-midAngle * RADIAN);
const label = payload.label;
const percent = round(payload.percent * 100, 1);
return (
<>
<text
x={xProcent}
y={yProcent}
fill="white"
textAnchor="middle"
dominantBaseline="central"
fontSize={10}
fontWeight={700}
pointerEvents={'none'}
>
{percent}%
</text>
<text
x={x}
y={y}
fill={fill}
textAnchor={x > cx ? 'start' : 'end'}
dominantBaseline="central"
fontSize={10}
>
{truncate(label, 20)}
</text>
</>
);
};

View File

@@ -0,0 +1,169 @@
import * as React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { Pagination, usePagination } from '@/components/Pagination';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useNumber } from '@/hooks/useNumerFormatter';
import { useSelector } from '@/redux';
import { getChartColor } from '@/utils/theme';
import { PreviousDiffIndicator } from '../PreviousDiffIndicator';
interface ReportTableProps {
data: IChartData;
visibleSeries: IChartData['series'];
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
}
export function ReportTable({
data,
visibleSeries,
setVisibleSeries,
}: ReportTableProps) {
const { setPage, paginate, page } = usePagination(50);
const number = useNumber();
const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval(interval);
const getLabel = useMappings();
function handleChange(name: string, checked: boolean) {
setVisibleSeries((prev) => {
if (checked) {
return [...prev, name];
} else {
return prev.filter((item) => item !== name);
}
});
}
return (
<>
<div className="grid grid-cols-[200px_1fr] border border-border rounded-md overflow-hidden">
<Table className="rounded-none border-t-0 border-l-0 border-b-0">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginate(data.series).map((serie, index) => {
const checked = !!visibleSeries.find(
(item) => item.name === serie.name
);
return (
<TableRow key={serie.name}>
<TableCell className="h-10">
<div className="flex items-center gap-2">
<Checkbox
onCheckedChange={(checked) =>
handleChange(serie.name, !!checked)
}
style={
checked
? {
background: getChartColor(index),
borderColor: getChartColor(index),
}
: undefined
}
checked={checked}
/>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<div className="min-w-0 overflow-hidden whitespace-nowrap text-ellipsis">
{getLabel(serie.name)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>{getLabel(serie.name)}</p>
</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<div className="overflow-auto">
<Table className="rounded-none border-none">
<TableHeader>
<TableRow>
<TableHead>Total</TableHead>
<TableHead>Average</TableHead>
{data.series[0]?.data.map((serie) => (
<TableHead
key={serie.date.toString()}
className="whitespace-nowrap"
>
{formatDate(serie.date)}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{paginate(data.series).map((serie) => {
return (
<TableRow key={serie.name}>
<TableCell className="h-10">
<div className="flex items-center gap-2 font-medium">
{number.format(serie.metrics.sum)}
<PreviousDiffIndicator
{...serie.metrics.previous.sum}
/>
</div>
</TableCell>
<TableCell className="h-10">
<div className="flex items-center gap-2 font-medium">
{number.format(serie.metrics.average)}
<PreviousDiffIndicator
{...serie.metrics.previous.average}
/>
</div>
</TableCell>
{serie.data.map((item) => {
return (
<TableCell className="h-10" key={item.date.toString()}>
<div className="flex items-center gap-2">
{number.format(item.count)}
<PreviousDiffIndicator {...item.previous} />
</div>
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
<div className="flex flex-col-reverse gap-4 md:flex-row md:justify-between md:items-center">
<div className="flex gap-1 flex-wrap">
<Badge>Total: {number.format(data.metrics.sum)}</Badge>
<Badge>Average: {number.format(data.metrics.average)}</Badge>
<Badge>Min: {number.format(data.metrics.min)}</Badge>
<Badge>Max: {number.format(data.metrics.max)}</Badge>
</div>
<Pagination cursor={page} setCursor={setPage} />
</div>
</>
);
}

View File

@@ -0,0 +1,36 @@
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 && 'card 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

@@ -0,0 +1,241 @@
import { useMemo } from 'react';
import type { LucideIcon, LucideProps } from 'lucide-react';
import {
ActivityIcon,
ExternalLinkIcon,
HelpCircleIcon,
MailIcon,
MonitorIcon,
MonitorPlayIcon,
PodcastIcon,
ScanIcon,
SearchIcon,
SmartphoneIcon,
TabletIcon,
} from 'lucide-react';
import { NOT_SET_VALUE } from '@mixan/constants';
interface SerieIconProps extends LucideProps {
name: string;
}
function getProxyImage(url: string) {
return `${String(process.env.NEXT_PUBLIC_API_URL)}/misc/favicon?url=${encodeURIComponent(url)}`;
}
const createImageIcon = (url: string) => {
return function (props: LucideProps) {
return <img className="w-4 h-4 object-cover rounded" src={url} />;
} as LucideIcon;
};
const createFlagIcon = (url: string) => {
return function (props: LucideProps) {
return (
<span className={`rounded !block !leading-[1rem] fi fi-${url}`}></span>
);
} as LucideIcon;
};
const mapper: Record<string, LucideIcon> = {
// Events
screen_view: MonitorPlayIcon,
session_start: ActivityIcon,
session_end: ActivityIcon,
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')),
twitter: createImageIcon(getProxyImage('https://x.com')),
duckduckgo: createImageIcon(getProxyImage('https://duckduckgo.com')),
'yahoo!': createImageIcon(getProxyImage('https://yahoo.com')),
instagram: createImageIcon(getProxyImage('https://instagram.com')),
gmail: createImageIcon(getProxyImage('https://mail.google.com/')),
'mobile safari': createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg'
)
),
chrome: createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/e/e1/Google_Chrome_icon_%28February_2022%29.svg'
)
),
'samsung internet': createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/thumb/e/e9/Samsung_Internet_logo.svg/1024px-Samsung_Internet_logo.svg.png'
)
),
safari: createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/5/52/Safari_browser_logo.svg'
)
),
edge: createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/7/7e/Microsoft_Edge_logo_%282019%29.png'
)
),
firefox: createImageIcon(
getProxyImage(
'https://upload.wikimedia.org/wikipedia/commons/thumb/a/a0/Firefox_logo%2C_2019.svg/1920px-Firefox_logo%2C_2019.svg.png'
)
),
snapchat: createImageIcon(getProxyImage('https://snapchat.com')),
// Misc
mobile: SmartphoneIcon,
desktop: MonitorIcon,
tablet: TabletIcon,
search: SearchIcon,
social: PodcastIcon,
email: MailIcon,
unknown: HelpCircleIcon,
[NOT_SET_VALUE]: ScanIcon,
// Flags
se: createFlagIcon('se'),
us: createFlagIcon('us'),
gb: createFlagIcon('gb'),
ua: createFlagIcon('ua'),
ru: createFlagIcon('ru'),
de: createFlagIcon('de'),
fr: createFlagIcon('fr'),
br: createFlagIcon('br'),
in: createFlagIcon('in'),
it: createFlagIcon('it'),
es: createFlagIcon('es'),
pl: createFlagIcon('pl'),
nl: createFlagIcon('nl'),
id: createFlagIcon('id'),
tr: createFlagIcon('tr'),
ph: createFlagIcon('ph'),
ca: createFlagIcon('ca'),
ar: createFlagIcon('ar'),
mx: createFlagIcon('mx'),
za: createFlagIcon('za'),
au: createFlagIcon('au'),
co: createFlagIcon('co'),
ch: createFlagIcon('ch'),
at: createFlagIcon('at'),
be: createFlagIcon('be'),
pt: createFlagIcon('pt'),
my: createFlagIcon('my'),
th: createFlagIcon('th'),
vn: createFlagIcon('vn'),
sg: createFlagIcon('sg'),
eg: createFlagIcon('eg'),
sa: createFlagIcon('sa'),
pk: createFlagIcon('pk'),
bd: createFlagIcon('bd'),
ro: createFlagIcon('ro'),
hu: createFlagIcon('hu'),
cz: createFlagIcon('cz'),
gr: createFlagIcon('gr'),
il: createFlagIcon('il'),
no: createFlagIcon('no'),
fi: createFlagIcon('fi'),
dk: createFlagIcon('dk'),
sk: createFlagIcon('sk'),
bg: createFlagIcon('bg'),
hr: createFlagIcon('hr'),
rs: createFlagIcon('rs'),
ba: createFlagIcon('ba'),
si: createFlagIcon('si'),
lv: createFlagIcon('lv'),
lt: createFlagIcon('lt'),
ee: createFlagIcon('ee'),
by: createFlagIcon('by'),
md: createFlagIcon('md'),
kz: createFlagIcon('kz'),
uz: createFlagIcon('uz'),
kg: createFlagIcon('kg'),
tj: createFlagIcon('tj'),
tm: createFlagIcon('tm'),
az: createFlagIcon('az'),
ge: createFlagIcon('ge'),
am: createFlagIcon('am'),
af: createFlagIcon('af'),
ir: createFlagIcon('ir'),
iq: createFlagIcon('iq'),
sy: createFlagIcon('sy'),
lb: createFlagIcon('lb'),
jo: createFlagIcon('jo'),
ps: createFlagIcon('ps'),
kw: createFlagIcon('kw'),
qa: createFlagIcon('qa'),
om: createFlagIcon('om'),
ye: createFlagIcon('ye'),
ae: createFlagIcon('ae'),
bh: createFlagIcon('bh'),
cy: createFlagIcon('cy'),
mt: createFlagIcon('mt'),
sm: createFlagIcon('sm'),
li: createFlagIcon('li'),
is: createFlagIcon('is'),
al: createFlagIcon('al'),
mk: createFlagIcon('mk'),
me: createFlagIcon('me'),
ad: createFlagIcon('ad'),
lu: createFlagIcon('lu'),
mc: createFlagIcon('mc'),
fo: createFlagIcon('fo'),
gg: createFlagIcon('gg'),
je: createFlagIcon('je'),
im: createFlagIcon('im'),
gi: createFlagIcon('gi'),
va: createFlagIcon('va'),
ax: createFlagIcon('ax'),
bl: createFlagIcon('bl'),
mf: createFlagIcon('mf'),
pm: createFlagIcon('pm'),
yt: createFlagIcon('yt'),
wf: createFlagIcon('wf'),
tf: createFlagIcon('tf'),
re: createFlagIcon('re'),
sc: createFlagIcon('sc'),
mu: createFlagIcon('mu'),
zw: createFlagIcon('zw'),
mz: createFlagIcon('mz'),
na: createFlagIcon('na'),
bw: createFlagIcon('bw'),
ls: createFlagIcon('ls'),
sz: createFlagIcon('sz'),
bi: createFlagIcon('bi'),
rw: createFlagIcon('rw'),
ug: createFlagIcon('ug'),
ke: createFlagIcon('ke'),
tz: createFlagIcon('tz'),
mg: createFlagIcon('mg'),
};
export function SerieIcon({ name, ...props }: SerieIconProps) {
const Icon = useMemo(() => {
const mapped = mapper[name.toLowerCase()] ?? null;
if (mapped) {
return mapped;
}
if (name.includes('http')) {
return createImageIcon(getProxyImage(name));
}
return null;
}, [name]);
return Icon ? (
<div className="w-4 h-4 flex-shrink-0 relative [&_a]:!w-4 [&_a]:!h-4 [&_svg]:!rounded">
<Icon size={16} {...props} />
</div>
) : null;
}

View File

@@ -0,0 +1,11 @@
const formatter = new Intl.NumberFormat('en', {
notation: 'compact',
});
export function getYAxisWidth(value: number) {
if (!isFinite(value)) {
return 7.8 + 7.8;
}
return formatter.format(value).toString().length * 7.8 + 7.8;
}

View File

@@ -0,0 +1,52 @@
'use client';
import type { IChartInput } from '@mixan/validation';
import { Funnel } from '../funnel';
import { Chart } from './Chart';
import { withChartProivder } from './ChartProvider';
export type ReportChartProps = IChartInput;
export const ChartSwitch = withChartProivder(function ChartSwitch(
props: ReportChartProps
) {
if (props.chartType === 'funnel') {
return <Funnel {...props} />;
}
return <Chart {...props} />;
});
interface ChartSwitchShortcutProps {
projectId: ReportChartProps['projectId'];
range?: ReportChartProps['range'];
previous?: ReportChartProps['previous'];
chartType?: ReportChartProps['chartType'];
interval?: ReportChartProps['interval'];
events: ReportChartProps['events'];
}
export const ChartSwitchShortcut = ({
projectId,
range = '7d',
previous = false,
chartType = 'linear',
interval = 'day',
events,
}: ChartSwitchShortcutProps) => {
return (
<ChartSwitch
projectId={projectId}
range={range}
breakdowns={[]}
previous={previous}
chartType={chartType}
interval={interval}
name="Random"
lineType="bump"
metric="sum"
events={events}
/>
);
};