migrate to app dir and ssr

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-01-20 22:54:38 +01:00
parent 719a82f1c4
commit 308ae98472
194 changed files with 4706 additions and 2194 deletions

View File

@@ -1,23 +1,27 @@
import { useDispatch, useSelector } from '@/redux';
import type { IChartType } from '@/types';
import { chartTypes } from '@/utils/constants';
import { objectToZodEnums } from '@/utils/validation';
import { Combobox } from '../ui/combobox';
import { changeChartType } from './reportSlice';
export function ReportChartType() {
interface ReportChartTypeProps {
className?: string;
}
export function ReportChartType({ className }: ReportChartTypeProps) {
const dispatch = useDispatch();
const type = useSelector((state) => state.report.chartType);
return (
<Combobox
className={className}
placeholder="Chart type"
onChange={(value) => {
dispatch(changeChartType(value as IChartType));
dispatch(changeChartType(value));
}}
value={type}
items={Object.entries(chartTypes).map(([key, value]) => ({
label: value,
items={objectToZodEnums(chartTypes).map((key) => ({
label: chartTypes[key],
value: key,
}))}
/>

View File

@@ -1,21 +1,28 @@
import { useDispatch, useSelector } from '@/redux';
import type { IInterval } from '@/types';
import { isMinuteIntervalEnabledByRange } from '@/utils/constants';
import {
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@/utils/constants';
import { Combobox } from '../ui/combobox';
import { changeInterval } from './reportSlice';
export function ReportInterval() {
interface ReportIntervalProps {
className?: string;
}
export function ReportInterval({ className }: ReportIntervalProps) {
const dispatch = useDispatch();
const interval = useSelector((state) => state.report.interval);
const range = useSelector((state) => state.report.range);
const chartType = useSelector((state) => state.report.chartType);
if (chartType !== 'linear') {
if (chartType !== 'linear' && chartType !== 'histogram') {
return null;
}
return (
<Combobox
className={className}
placeholder="Interval"
onChange={(value) => {
dispatch(changeInterval(value as IInterval));
@@ -30,6 +37,7 @@ export function ReportInterval() {
{
value: 'hour',
label: 'Hour',
disabled: !isHourIntervalEnabledByRange(range),
},
{
value: 'day',

View File

@@ -0,0 +1,34 @@
import { useDispatch, useSelector } from '@/redux';
import { lineTypes } from '@/utils/constants';
import { objectToZodEnums } from '@/utils/validation';
import { Combobox } from '../ui/combobox';
import { changeLineType } from './reportSlice';
interface ReportLineTypeProps {
className?: string;
}
export function ReportLineType({ className }: ReportLineTypeProps) {
const dispatch = useDispatch();
const chartType = useSelector((state) => state.report.chartType);
const type = useSelector((state) => state.report.lineType);
if (chartType != 'linear' && chartType != 'area') {
return null;
}
return (
<Combobox
className={className}
placeholder="Line type"
onChange={(value) => {
dispatch(changeLineType(value));
}}
value={type}
items={objectToZodEnums(lineTypes).map((key) => ({
label: lineTypes[key],
value: key,
}))}
/>
);
}

View File

@@ -1,15 +1,20 @@
'use client';
import { api, handleError } from '@/app/_trpc/client';
import { Button } from '@/components/ui/button';
import { toast } from '@/components/ui/use-toast';
import { pushModal } from '@/modals';
import { useDispatch, useSelector } from '@/redux';
import { api, handleError } from '@/utils/api';
import { SaveIcon } from 'lucide-react';
import { useParams } from 'next/navigation';
import { useReportId } from './hooks/useReportId';
import { resetDirty } from './reportSlice';
export function ReportSaveButton() {
const { reportId } = useReportId();
interface ReportSaveButtonProps {
className?: string;
}
export function ReportSaveButton({ className }: ReportSaveButtonProps) {
const { reportId } = useParams();
const dispatch = useDispatch();
const update = api.report.update.useMutation({
onSuccess() {
@@ -26,11 +31,12 @@ export function ReportSaveButton() {
if (reportId) {
return (
<Button
className={className}
disabled={!report.dirty}
loading={update.isLoading}
onClick={() => {
update.mutate({
reportId,
reportId: reportId as string,
report,
});
}}
@@ -42,6 +48,7 @@ export function ReportSaveButton() {
} else {
return (
<Button
className={className}
disabled={!report.dirty}
onClick={() => {
pushModal('SaveReport', {

View File

@@ -24,6 +24,9 @@ export const ChartAnimationContainer = (
) => (
<div
{...props}
className={cn('border border-border rounded-md p-8', props.className)}
className={cn(
'border border-border rounded-md p-8 bg-white',
props.className
)}
/>
);

View File

@@ -1,3 +1,5 @@
'use client';
import React, { useEffect, useRef } from 'react';
import { useInViewport } from 'react-in-viewport';

View File

@@ -0,0 +1,104 @@
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartLineType, IInterval } from '@/types';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import {
Area,
AreaChart,
CartesianGrid,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
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(data);
return (
<>
<div
className={cn(
'max-sm:-mx-3',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) => (
<AreaChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
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) => {
return (
<Area
key={serie.name}
type={lineType}
isAnimationActive={false}
strokeWidth={0}
dataKey={`${serie.index}:count`}
stroke={getChartColor(serie.index)}
fill={getChartColor(serie.index)}
stackId={'1'}
fillOpacity={1}
/>
);
})}
<CartesianGrid
strokeDasharray="3 3"
horizontal={true}
vertical={false}
/>
</AreaChart>
)}
</AutoSizer>
</div>
{editMode && (
<ReportTable
data={data}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}
</>
);
}

View File

@@ -1,4 +1,5 @@
import { useMemo, useState } from 'react';
import type { IChartData, RouterOutputs } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import {
Table,
@@ -8,8 +9,6 @@ import {
TableHeader,
TableRow,
} from '@/components/ui/table';
import type { IChartData } from '@/types';
import type { RouterOutputs } from '@/utils/api';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import {
@@ -36,6 +35,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
const { editMode } = useChartContext();
const [ref, { width }] = useElementSize();
const [sorting, setSorting] = useState<SortingState>([]);
const maxCount = Math.max(...data.series.map((serie) => serie.metrics.sum));
const table = useReactTable({
data: useMemo(
() => (editMode ? data.series : data.series.slice(0, 20)),
@@ -57,7 +57,7 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
footer: (info) => info.column.id,
size: width ? width * 0.3 : undefined,
}),
columnHelper.accessor((row) => row.metrics.total, {
columnHelper.accessor((row) => row.metrics.sum, {
id: 'totalCount',
cell: (info) => (
<div className="text-right font-medium">{info.getValue()}</div>
@@ -67,15 +67,13 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
size: width ? width * 0.1 : undefined,
enableSorting: true,
}),
columnHelper.accessor((row) => row.metrics.total, {
columnHelper.accessor((row) => row.metrics.sum, {
id: 'graph',
cell: (info) => (
<div
className="shine h-4 rounded [.mini_&]:h-3"
style={{
width:
(info.getValue() / info.row.original.meta.highest) * 100 +
'%',
width: (info.getValue() / maxCount) * 100 + '%',
background: getChartColor(info.row.index),
}}
/>
@@ -93,30 +91,10 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(),
// debugTable: true,
// debugHeaders: true,
// debugColumns: true,
});
return (
<div ref={ref}>
{editMode && (
<div className="mb-8 flex flex-wrap gap-4">
{data.events.map((event) => {
return (
<div className="border border-border p-4" key={event.id}>
<div className="flex items-center gap-2 text-lg font-medium">
<ColorSquare>{event.id}</ColorSquare> {event.name}
</div>
<div className="mt-6 font-mono text-5xl font-light">
{new Intl.NumberFormat('en-IN', {
maximumSignificantDigits: 20,
}).format(event.count)}
</div>
</div>
);
})}
</div>
)}
<div className="overflow-x-auto">
<Table
{...{

View File

@@ -2,19 +2,19 @@ import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useSelector } from '@/redux';
import type { IToolTipProps } from '@/types';
import { alphabetIds } from '@/utils/constants';
type ReportLineChartTooltipProps = IToolTipProps<{
color: string;
value: number;
dataKey: string;
payload: {
date: Date;
count: number;
label: string;
color: string;
} & Record<string, any>;
}>;
export function ReportLineChartTooltip({
export function ReportChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
@@ -34,31 +34,32 @@ export function ReportLineChartTooltip({
const sorted = payload.slice(0).sort((a, b) => b.value - a.value);
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
const first = visible[0]!;
const isBarChart = first.payload.count === undefined;
return (
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl">
{formatDate(new Date(first.payload.date))}
{visible.map((item, index) => {
const id = alphabetIds[index];
// If we have a <Cell /> component, payload can be nested
const payload = item.payload.payload ?? item.payload;
const data = item.dataKey.includes(':')
? payload[`${item.dataKey.split(':')[0]}:payload`]
: payload;
return (
<div key={item.payload.label} className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: item.color }}
></div>
<div className="flex flex-col">
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{isBarChart
? item.payload[`${id}:label`]
: getLabel(item.payload.label)}
</div>
<div>
{isBarChart ? item.payload[`${id}:count`] : item.payload.count}
<>
{index === 0 && data.date ? formatDate(new Date(data.date)) : null}
<div key={item.payload.label} className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: data.color }}
/>
<div className="flex flex-col">
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{getLabel(data.label)}
</div>
<div>{data.count}</div>
</div>
</div>
</div>
</>
);
})}
{hidden.length > 0 && (

View File

@@ -1,13 +1,16 @@
import { useEffect, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import type { IChartData, IInterval } from '@/types';
import { alphabetIds } from '@/utils/constants';
import { getChartColor } from '@/utils/theme';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IInterval } from '@/types';
import { cn } from '@/utils/cn';
import { getChartColor, theme } from '@/utils/theme';
import { Bar, BarChart, CartesianGrid, Tooltip, XAxis, YAxis } from 'recharts';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportLineChartTooltip } from './ReportLineChartTooltip';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
interface ReportHistogramChartProps {
@@ -15,80 +18,61 @@ interface ReportHistogramChartProps {
interval: IInterval;
}
function BarHover(props: any) {
const bg = theme?.colors?.slate?.['200'] as string;
return <rect {...props} rx="8" fill={bg} fill-opacity={0.5} />;
}
export function ReportHistogramChart({
interval,
data,
}: ReportHistogramChartProps) {
const { editMode } = useChartContext();
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
const formatDate = useFormatDateInterval(interval);
const { series, setVisibleSeries } = useVisibleSeries(data);
const ref = useRef(false);
useEffect(() => {
if (!ref.current && data) {
const max = 20;
setVisibleSeries(
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
);
// ref.current = true;
}
}, [data]);
const rel = data.series[0]?.data.map(({ date }) => {
return {
date,
...data.series.reduce((acc, serie, idx) => {
return {
...acc,
...serie.data.reduce(
(acc2, item) => {
const id = alphabetIds[idx];
if (item.date === date) {
acc2[`${id}:count`] = item.count;
acc2[`${id}:label`] = item.label;
}
return acc2;
},
{} as Record<string, any>
),
};
}, {}),
};
});
const rechartData = useRechartDataModel(data);
return (
<>
<div className="max-sm:-mx-3">
<div
className={cn(
'max-sm:-mx-3',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) => (
<BarChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
data={rel}
data={rechartData}
>
<CartesianGrid strokeDasharray="3 3" />
<Tooltip content={<ReportLineChartTooltip />} />
<CartesianGrid strokeDasharray="3 3" vertical={false} />
<Tooltip content={<ReportChartTooltip />} cursor={<BarHover />} />
<XAxis
fontSize={12}
dataKey="date"
tickFormatter={formatDate}
tickLine={false}
axisLine={false}
/>
{data.series.map((serie, index) => {
const id = alphabetIds[index];
<YAxis
fontSize={12}
axisLine={false}
tickLine={false}
width={getYAxisWidth(data.metrics.max)}
/>
{series.map((serie) => {
return (
<>
<YAxis dataKey={`${id}:count`} fontSize={12}></YAxis>
<Bar
stackId={id}
key={serie.name}
isAnimationActive={false}
name={serie.name}
dataKey={`${id}:count`}
fill={getChartColor(index)}
/>
</>
<Bar
stackId={serie.index}
key={serie.name}
name={serie.name}
dataKey={`${serie.index}:count`}
fill={getChartColor(serie.index)}
radius={8}
/>
);
})}
</BarChart>
@@ -98,7 +82,7 @@ export function ReportHistogramChart({
{editMode && (
<ReportTable
data={data}
visibleSeries={visibleSeries}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}

View File

@@ -1,7 +1,11 @@
import { useEffect, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import type { IChartData, IInterval } from '@/types';
import { useRechartDataModel } from '@/hooks/useRechartDataModel';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import type { IChartLineType, IInterval } from '@/types';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
import {
CartesianGrid,
@@ -12,76 +16,76 @@ import {
YAxis,
} from 'recharts';
import { getYAxisWidth } from './chart-utils';
import { useChartContext } from './ChartProvider';
import { ReportLineChartTooltip } from './ReportLineChartTooltip';
import { ReportChartTooltip } from './ReportChartTooltip';
import { ReportTable } from './ReportTable';
interface ReportLineChartProps {
data: IChartData;
interval: IInterval;
lineType: IChartLineType;
}
export function ReportLineChart({ interval, data }: ReportLineChartProps) {
export function ReportLineChart({
lineType,
interval,
data,
}: ReportLineChartProps) {
const { editMode } = useChartContext();
const [visibleSeries, setVisibleSeries] = useState<string[]>([]);
const formatDate = useFormatDateInterval(interval);
const ref = useRef(false);
useEffect(() => {
if (!ref.current && data) {
const max = 20;
setVisibleSeries(
data?.series?.slice(0, max).map((serie) => serie.name) ?? []
);
// ref.current = true;
}
}, [data]);
const { series, setVisibleSeries } = useVisibleSeries(data);
const rechartData = useRechartDataModel(data);
return (
<>
<div className="max-sm:-mx-3">
<div
className={cn(
'max-sm:-mx-3',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) => (
<LineChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
data={rechartData}
>
<YAxis dataKey={'count'} fontSize={12}></YAxis>
<Tooltip content={<ReportLineChartTooltip />} />
<CartesianGrid strokeDasharray="3 3" />
<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) => {
return formatDate(m);
}}
tickFormatter={(m: string) => formatDate(m)}
tickLine={false}
allowDuplicatedCategory={false}
/>
{data?.series
.filter((serie) => {
return visibleSeries.includes(serie.name);
})
.map((serie) => {
const realIndex = data?.series.findIndex(
(item) => item.name === serie.name
);
const key = serie.name;
const strokeColor = getChartColor(realIndex);
return (
<Line
type="monotone"
key={key}
isAnimationActive={false}
strokeWidth={2}
dataKey="count"
stroke={strokeColor}
data={serie.data}
name={serie.name}
/>
);
})}
{series.map((serie) => {
return (
<Line
type={lineType}
key={serie.name}
isAnimationActive={false}
strokeWidth={2}
dataKey={`${serie.index}:count`}
stroke={getChartColor(serie.index)}
name={serie.name}
/>
);
})}
</LineChart>
)}
</AutoSizer>
@@ -89,7 +93,7 @@ export function ReportLineChart({ interval, data }: ReportLineChartProps) {
{editMode && (
<ReportTable
data={data}
visibleSeries={visibleSeries}
visibleSeries={series}
setVisibleSeries={setVisibleSeries}
/>
)}

View File

@@ -0,0 +1,79 @@
import type { IChartData } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { theme } from '@/utils/theme';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Area, AreaChart } from 'recharts';
import { useChartContext } from './ChartProvider';
interface ReportMetricChartProps {
data: IChartData;
}
export function ReportMetricChart({ data }: ReportMetricChartProps) {
const { editMode } = useChartContext();
const { series } = useVisibleSeries(data, editMode ? undefined : 2);
const color = theme?.colors['chart-0'];
return (
<div
className={cn(
'grid grid-cols-1 gap-4',
editMode && 'md:grid-cols-2 lg:grid-cols-3'
)}
>
{series.map((serie) => {
return (
<div
className="relative border border-border p-4 rounded-md bg-white overflow-hidden"
key={serie.name}
>
<div className="absolute -top-1 -left-1 -right-1 -bottom-1 z-0 opacity-50">
<AutoSizer>
{({ width, height }) => (
<AreaChart
width={width}
height={height / 3}
data={serie.data}
style={{ marginTop: (height / 3) * 2 }}
>
<defs>
<linearGradient id="area" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.3} />
<stop
offset="95%"
stopColor={color}
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
dataKey="count"
type="monotone"
fill="url(#area)"
fillOpacity={1}
stroke={color}
strokeWidth={2}
/>
</AreaChart>
)}
</AutoSizer>
</div>
<div className="relative">
<div className="flex items-center gap-2 text-lg font-medium">
<ColorSquare>{serie.event.id}</ColorSquare>
{serie.name ?? serie.event.displayName ?? serie.event.name}
</div>
<div className="mt-6 font-mono text-4xl font-light">
{new Intl.NumberFormat('en', {
maximumSignificantDigits: 20,
}).format(serie.metrics.sum)}
</div>
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,81 @@
import { useEffect, useRef, useState } from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { AutoSizer } from '@/components/AutoSizer';
import { useVisibleSeries } from '@/hooks/useVisibleSeries';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
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);
// Get max 10 series and than combine others into one
const pieData = series.map((serie) => {
return {
id: serie.name,
color: getChartColor(serie.index),
index: serie.index,
label: serie.name,
count: serie.metrics.sum,
};
});
return (
<>
<div
className={cn(
'max-sm:-mx-3',
editMode && 'border border-border bg-white rounded-md p-4'
)}
>
<AutoSizer disableHeight>
{({ width }) => {
const height = Math.min(Math.max(width * 0.5, 250), 400);
return (
<PieChart
width={width}
height={Math.min(Math.max(width * 0.5, 250), 400)}
>
<Tooltip content={<ReportChartTooltip />} />
<Pie
dataKey={'count'}
data={pieData}
innerRadius={height / 4}
outerRadius={height / 2 - 20}
isAnimationActive={false}
>
{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}
/>
)}
</>
);
}

View File

@@ -1,15 +1,20 @@
import * as React from 'react';
import type { IChartData } from '@/app/_trpc/client';
import { Checkbox } from '@/components/ui/checkbox';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useFormatDateInterval } from '@/hooks/useFormatDateInterval';
import { useMappings } from '@/hooks/useMappings';
import { useSelector } from '@/redux';
import type { IChartData } from '@/types';
import { cn } from '@/utils/cn';
import { getChartColor } from '@/utils/theme';
interface ReportTableProps {
data: IChartData;
visibleSeries: string[];
visibleSeries: IChartData['series'];
setVisibleSeries: React.Dispatch<React.SetStateAction<string[]>>;
}
@@ -40,31 +45,23 @@ export function ReportTable({
'bg-gray-50 text-emerald-600 font-medium border-r border-border';
return (
<>
<div className="flex w-fit max-w-full rounded-md border border-border">
<div className="flex w-fit max-w-full rounded-md border border-border bg-white">
{/* Labels */}
<div className="border-r border-border">
<div className={cn(header, row, cell)}>Name</div>
{/* <div
className={cn(
'flex max-w-[200px] w-full min-w-full items-center gap-2',
row,
cell
)}
>
<div className="font-medium min-w-0 overflow-scroll whitespace-nowrap scrollbar-hide">
Summary
</div>
</div> */}
{data.series.map((serie, index) => {
const checked = visibleSeries.includes(serie.name);
const checked = !!visibleSeries.find(
(item) => item.name === serie.name
);
return (
<div
key={serie.name}
className={cn(
'flex max-w-[200px] w-full min-w-full items-center gap-2',
'flex max-w-[200px] lg:max-w-[400px] xl:max-w-[600px] w-full min-w-full items-center gap-2',
row,
cell
// avoid using cell since its better on the right side
'p-2'
)}
>
<Checkbox
@@ -81,12 +78,16 @@ export function ReportTable({
}
checked={checked}
/>
<div
title={getLabel(serie.name)}
className="min-w-full overflow-scroll whitespace-nowrap scrollbar-hide"
>
{getLabel(serie.name)}
</div>
<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>
);
})}
@@ -113,7 +114,7 @@ export function ReportTable({
return (
<div className={cn('w-max', row)} key={serie.name}>
<div className={cn(header, value, cell, total)}>
{serie.metrics.total}
{serie.metrics.sum}
</div>
<div className={cn(header, value, cell, total)}>
{serie.metrics.average}
@@ -130,14 +131,22 @@ export function ReportTable({
})}
</div>
</div>
<div className="flex gap-2">
<div>Total</div>
<div>
{data.series.reduce((acc, serie) => serie.metrics.total + acc, 0)}
<div className="flex gap-4">
<div className="flex gap-1">
<div>Total</div>
<div>{data.metrics.sum}</div>
</div>
<div>Average</div>
<div>
{data.series.reduce((acc, serie) => serie.metrics.average + acc, 0)}
<div className="flex gap-1">
<div>Average</div>
<div>{data.metrics.averge}</div>
</div>
<div className="flex gap-1">
<div>Min</div>
<div>{data.metrics.min}</div>
</div>
<div className="flex gap-1">
<div>Max</div>
<div>{data.metrics.max}</div>
</div>
</div>
</>

View File

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

View File

@@ -1,13 +1,18 @@
'use client';
import { memo } from 'react';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types';
import { api } from '@/utils/api';
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
import { withChartProivder } from './ChartProvider';
import { ReportAreaChart } from './ReportAreaChart';
import { ReportBarChart } from './ReportBarChart';
import { ReportHistogramChart } from './ReportHistogramChart';
import { ReportLineChart } from './ReportLineChart';
import { ReportMetricChart } from './ReportMetricChart';
import { ReportPieChart } from './ReportPieChart';
export type ReportChartProps = IChartInput;
@@ -19,8 +24,9 @@ export const Chart = memo(
chartType,
name,
range,
lineType,
}: ReportChartProps) {
const params = useOrganizationParams();
const params = useAppParams();
const hasEmptyFilters = events.some((event) =>
event.filters.some((filter) => filter.value.length === 0)
);
@@ -29,13 +35,15 @@ export const Chart = memo(
{
interval,
chartType,
// dont send lineType since it does not need to be sent
lineType: 'monotone',
events,
breakdowns,
name,
range,
startDate: null,
endDate: null,
projectSlug: params.project,
projectId: params.projectId,
},
{
keepPreviousData: false,
@@ -97,8 +105,32 @@ export const Chart = memo(
return <ReportBarChart data={chart.data} />;
}
if (chartType === 'metric') {
return <ReportMetricChart data={chart.data} />;
}
if (chartType === 'pie') {
return <ReportPieChart data={chart.data} />;
}
if (chartType === 'linear') {
return <ReportLineChart interval={interval} data={chart.data} />;
return (
<ReportLineChart
lineType={lineType}
interval={interval}
data={chart.data}
/>
);
}
if (chartType === 'area') {
return (
<ReportAreaChart
lineType={lineType}
interval={interval}
data={chart.data}
/>
);
}
return (

View File

@@ -1,9 +0,0 @@
import { useQueryParams } from '@/hooks/useQueryParams';
import { z } from 'zod';
export const useReportId = () =>
useQueryParams(
z.object({
reportId: z.string().optional(),
})
);

View File

@@ -2,25 +2,34 @@ import type {
IChartBreakdown,
IChartEvent,
IChartInput,
IChartLineType,
IChartRange,
IChartType,
IInterval,
} from '@/types';
import { alphabetIds, isMinuteIntervalEnabledByRange } from '@/utils/constants';
import {
alphabetIds,
getDefaultIntervalByRange,
isHourIntervalEnabledByRange,
isMinuteIntervalEnabledByRange,
} from '@/utils/constants';
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
type InitialState = IChartInput & {
dirty: boolean;
ready: boolean;
startDate: string | null;
endDate: string | null;
};
// First approach: define the initial state using that type
const initialState: InitialState = {
ready: false,
dirty: false,
name: 'Untitled',
chartType: 'linear',
lineType: 'monotone',
interval: 'day',
breakdowns: [],
events: [],
@@ -42,12 +51,19 @@ export const reportSlice = createSlice({
reset() {
return initialState;
},
ready() {
return {
...initialState,
ready: true,
};
},
setReport(state, action: PayloadAction<IChartInput>) {
return {
...action.payload,
startDate: null,
endDate: null,
dirty: false,
ready: true,
};
},
setName(state, action: PayloadAction<string>) {
@@ -132,6 +148,19 @@ export const reportSlice = createSlice({
) {
state.interval = 'hour';
}
if (
!isHourIntervalEnabledByRange(state.range) &&
state.interval === 'hour'
) {
state.interval = 'day';
}
},
// Line type
changeLineType: (state, action: PayloadAction<IChartLineType>) => {
state.dirty = true;
state.lineType = action.payload;
},
// Date range
@@ -149,15 +178,7 @@ export const reportSlice = createSlice({
changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
state.dirty = true;
state.range = action.payload;
if (action.payload === '30min' || action.payload === '1h') {
state.interval = 'minute';
} else if (action.payload === 'today' || action.payload === '24h') {
state.interval = 'hour';
} else if (action.payload === '7d' || action.payload === '14d') {
state.interval = 'day';
} else {
state.interval = 'month';
}
state.interval = getDefaultIntervalByRange(action.payload);
},
},
});
@@ -165,6 +186,7 @@ export const reportSlice = createSlice({
// Action creators are generated for each case reducer function
export const {
reset,
ready,
setReport,
setName,
addEvent,
@@ -176,6 +198,7 @@ export const {
changeInterval,
changeDateRanges,
changeChartType,
changeLineType,
resetDirty,
} = reportSlice.actions;

View File

@@ -1,20 +1,22 @@
'use client';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Combobox } from '@/components/ui/combobox';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useDispatch, useSelector } from '@/redux';
import type { IChartBreakdown } from '@/types';
import { api } from '@/utils/api';
import { useParams } from 'next/navigation';
import { addBreakdown, changeBreakdown, removeBreakdown } from '../reportSlice';
import { ReportBreakdownMore } from './ReportBreakdownMore';
import type { ReportEventMoreProps } from './ReportEventMore';
export function ReportBreakdowns() {
const params = useOrganizationParams();
const params = useParams();
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery({
projectSlug: params.project,
projectId: params.projectId as string,
});
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
value: item,
@@ -43,6 +45,8 @@ export function ReportBreakdowns() {
<div className="flex items-center gap-2 p-2 px-4">
<ColorSquare>{index}</ColorSquare>
<Combobox
className="flex-1"
searchable
value={item.name}
onChange={(value) => {
dispatch(
@@ -63,6 +67,7 @@ export function ReportBreakdowns() {
{selectedBreakdowns.length === 0 && (
<Combobox
searchable
value={''}
onChange={(value) => {
dispatch(

View File

@@ -1,9 +1,9 @@
import type { Dispatch } from 'react';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { Button } from '@/components/ui/button';
import { ComboboxAdvanced } from '@/components/ui/combobox-advanced';
import { ComboboxMulti } from '@/components/ui/combobox-multi';
import {
CommandDialog,
CommandEmpty,
@@ -15,16 +15,15 @@ import {
} from '@/components/ui/command';
import { RenderDots } from '@/components/ui/RenderDots';
import { useMappings } from '@/hooks/useMappings';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useDispatch, useSelector } from '@/redux';
import { useDispatch } from '@/redux';
import type {
IChartEvent,
IChartEventFilter,
IChartEventFilterValue,
} from '@/types';
import { api } from '@/utils/api';
import { operators } from '@/utils/constants';
import { CreditCard, SlidersHorizontal, Trash } from 'lucide-react';
import { useParams } from 'next/navigation';
import { changeEvent } from '../reportSlice';
@@ -39,12 +38,12 @@ export function ReportEventFilters({
isCreating,
setIsCreating,
}: ReportEventFiltersProps) {
const params = useOrganizationParams();
const params = useParams();
const dispatch = useDispatch();
const propertiesQuery = api.chart.properties.useQuery(
{
event: event.name,
projectSlug: params.project,
projectId: params.projectId as string,
},
{
enabled: !!event.name,
@@ -103,13 +102,13 @@ interface FilterProps {
}
function Filter({ filter, event }: FilterProps) {
const params = useOrganizationParams();
const params = useParams<{ organizationId: string; projectId: string }>();
const getLabel = useMappings();
const dispatch = useDispatch();
const potentialValues = api.chart.values.useQuery({
event: event.name,
property: filter.name,
projectSlug: params.project,
projectId: params?.projectId!,
});
const valuesCombobox =
@@ -196,8 +195,8 @@ function Filter({ filter, event }: FilterProps) {
</Dropdown>
<ComboboxAdvanced
items={valuesCombobox}
selected={filter.value}
setSelected={(setFn) => {
value={filter.value}
onChange={(setFn) => {
changeFilterValue(
typeof setFn === 'function' ? setFn(filter.value) : setFn
);

View File

@@ -1,14 +1,16 @@
'use client';
import { useState } from 'react';
import { api } from '@/app/_trpc/client';
import { ColorSquare } from '@/components/ColorSquare';
import { Dropdown } from '@/components/Dropdown';
import { Combobox } from '@/components/ui/combobox';
import { Input } from '@/components/ui/input';
import { useDebounceFn } from '@/hooks/useDebounceFn';
import { useOrganizationParams } from '@/hooks/useOrganizationParams';
import { useDispatch, useSelector } from '@/redux';
import type { IChartEvent } from '@/types';
import { api } from '@/utils/api';
import { Filter, GanttChart, Users } from 'lucide-react';
import { useParams } from 'next/navigation';
import { addEvent, changeEvent, removeEvent } from '../reportSlice';
import { ReportEventFilters } from './ReportEventFilters';
@@ -19,9 +21,9 @@ export function ReportEvents() {
const [isCreating, setIsCreating] = useState(false);
const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch();
const params = useOrganizationParams();
const params = useParams();
const eventsQuery = api.chart.events.useQuery({
projectSlug: params.project,
projectId: String(params.projectId),
});
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
value: item.name,
@@ -56,6 +58,8 @@ export function ReportEvents() {
<div className="flex items-center gap-2 p-2">
<ColorSquare>{event.id}</ColorSquare>
<Combobox
className="flex-1"
searchable
value={event.name}
onChange={(value) => {
dispatch(