feat: dashboard v2, esm, upgrades (#211)

* esm

* wip

* wip

* wip

* wip

* wip

* wip

* subscription notice

* wip

* wip

* wip

* fix envs

* fix: update docker build

* fix

* esm/types

* delete dashboard :D

* add patches to dockerfiles

* update packages + catalogs + ts

* wip

* remove native libs

* ts

* improvements

* fix redirects and fetching session

* try fix favicon

* fixes

* fix

* order and resize reportds within a dashboard

* improvements

* wip

* added userjot to dashboard

* fix

* add op

* wip

* different cache key

* improve date picker

* fix table

* event details loading

* redo onboarding completely

* fix login

* fix

* fix

* extend session, billing and improve bars

* fix

* reduce price on 10M
This commit is contained in:
Carl-Gerhard Lindesvärd
2025-10-16 12:27:44 +02:00
committed by GitHub
parent 436e81ecc9
commit 81a7e5d62e
741 changed files with 32695 additions and 16996 deletions

View File

@@ -0,0 +1,16 @@
import { useRouteContext } from '@tanstack/react-router';
export function useAppContext() {
const params = useRouteContext({
strict: false,
});
if (!params.apiUrl || !params.dashboardUrl) {
throw new Error('API URL or dashboard URL is not set');
}
return {
apiUrl: params.apiUrl,
dashboardUrl: params.dashboardUrl,
};
}

View File

@@ -0,0 +1,11 @@
import { useParams } from '@tanstack/react-router';
export function useAppParams() {
const params = useParams({
strict: false,
});
return params as {
organizationId: string;
projectId: string;
};
}

View File

@@ -0,0 +1,32 @@
import { useMediaQuery } from 'react-responsive';
// import type { ScreensConfig } from 'tailwindcss/types/config';
// TODO: Ensure we have same breakpoints as tailwind
// const breakpoints = theme?.screens ?? {
const breakpoints = {
xs: '480px',
sm: '640px',
md: '768px',
lg: '1024px',
xl: '1280px',
};
type ScreensConfig = typeof breakpoints;
export function useBreakpoint<K extends string>(breakpointKey: K) {
const breakpointValue = breakpoints[breakpointKey as keyof ScreensConfig];
const bool = useMediaQuery({
query: `(max-width: ${breakpointValue as string})`,
});
const capitalizedKey =
breakpointKey[0]?.toUpperCase() + breakpointKey.substring(1);
type KeyAbove = `isAbove${Capitalize<K>}`;
type KeyBelow = `isBelow${Capitalize<K>}`;
return {
[breakpointKey]: Number(String(breakpointValue).replace(/[^0-9]/g, '')),
[`isAbove${capitalizedKey}`]: !bool,
[`isBelow${capitalizedKey}`]: bool,
} as Record<K, number> & Record<KeyAbove | KeyBelow, boolean>;
}

View File

@@ -0,0 +1,27 @@
import * as React from 'react';
/**
* @see https://github.com/radix-ui/primitives/blob/main/packages/react/use-callback-ref/src/useCallbackRef.tsx
*/
/**
* A custom hook that converts a callback to a ref to avoid triggering re-renders when passed as a
* prop or avoid re-executing effects when passed as a dependency
*/
function useCallbackRef<T extends (...args: never[]) => unknown>(
callback: T | undefined,
): T {
const callbackRef = React.useRef(callback);
React.useEffect(() => {
callbackRef.current = callback;
});
// https://github.com/facebook/react/issues/19240
return React.useMemo(
() => ((...args) => callbackRef.current?.(...args)) as T,
[],
);
}
export { useCallbackRef };

View File

@@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
export const ONBOARDING_SECRET_KEY = 'onboarding.clientSecret';
const DEFAULT_SECRET = '[CLIENT_SECRET]';
export function useClientSecret() {
const [clientSecret, setClientSecret] = useState<string>(DEFAULT_SECRET);
useEffect(() => {
if (clientSecret && DEFAULT_SECRET !== clientSecret) {
sessionStorage.setItem(ONBOARDING_SECRET_KEY, clientSecret);
}
}, [clientSecret]);
useEffect(() => {
const clientSecret = sessionStorage.getItem(ONBOARDING_SECRET_KEY);
if (clientSecret) {
setClientSecret(clientSecret);
}
}, []);
return [clientSecret, setClientSecret] as const;
}

View File

@@ -0,0 +1,199 @@
import { forwardRef, useCallback, useRef, useState } from 'react';
import { Customized, Line } from 'recharts';
export type GraphicalItemPoint = {
/**
* x point coordinate.
*/
x?: number;
/**
* y point coordinate.
*/
y?: number;
};
export type GraphicalItemProps = {
/**
* graphical item points.
*/
points?: GraphicalItemPoint[];
};
export type ItemProps = {
/**
* item data key.
*/
dataKey?: string;
};
export type ItemType = {
/**
* recharts item display name.
*/
displayName?: string;
};
export type Item = {
/**
* item props.
*/
props?: ItemProps;
/**
* recharts item class.
*/
type?: ItemType;
};
export type GraphicalItem = {
/**
* from recharts internal state and props of chart.
*/
props?: GraphicalItemProps;
/**
* from recharts internal state and props of chart.
*/
item?: Item;
};
export type RechartsChartProps = {
/**
* from recharts internal state and props of chart.
*/
formattedGraphicalItems?: GraphicalItem[];
};
export type CalculateStrokeDasharray = (props?: any) => any;
export type LineStrokeDasharray = {
/**
* line name.
*/
name?: string;
/**
* line strokeDasharray.
*/
strokeDasharray?: string;
};
export type LinesStrokeDasharray = LineStrokeDasharray[];
export type LineProps = {
/**
* line name.
*/
name?: string;
/**
* specifies the starting index of the first dot in the dash pattern.
*/
dotIndex?: number;
/**
* defines the pattern of dashes and gaps. an array of [gap length, dash length].
*/
strokeDasharray?: [number, number];
/**
* adjusts the percentage correction of the first line segment for better alignment in curved lines.
*/
curveCorrection?: number;
};
export type UseStrokeDasharrayProps = {
/**
* an array of properties to target specific line(s) and override default settings.
*/
linesProps?: LineProps[];
} & LineProps;
export function useStrokeDasharray({
linesProps = [],
dotIndex = -2,
strokeDasharray: restStroke = [5, 3],
curveCorrection = 1,
}: UseStrokeDasharrayProps): [CalculateStrokeDasharray, LinesStrokeDasharray] {
const linesStrokeDasharray = useRef<LinesStrokeDasharray>([]);
const calculateStrokeDasharray = useCallback(
(props: RechartsChartProps): null => {
const items = props?.formattedGraphicalItems;
const getLineWidth = (points: GraphicalItemPoint[]) => {
const width = points?.reduce((acc, point, index) => {
if (!index) return acc;
const prevPoint = points?.[index - 1];
const xAxis = point?.x || 0;
const prevXAxis = prevPoint?.x || 0;
const xWidth = xAxis - prevXAxis;
const yAxis = point?.y || 0;
const prevYAxis = prevPoint?.y || 0;
const yWidth = Math.abs(yAxis - prevYAxis);
const hypotenuse = Math.sqrt(xWidth * xWidth + yWidth * yWidth);
acc += hypotenuse;
return acc;
}, 0);
return width || 0;
};
items?.forEach((line) => {
const linePoints = line?.props?.points;
const lineWidth = getLineWidth(linePoints || []);
const name = line?.item?.props?.dataKey;
const targetLine = linesProps?.find((target) => target?.name === name);
const targetIndex = targetLine?.dotIndex ?? dotIndex;
const dashedPoints = linePoints?.slice(targetIndex);
const dashedWidth = getLineWidth(dashedPoints || []);
if (!lineWidth || !dashedWidth) return;
const firstWidth = lineWidth - dashedWidth;
const targetCurve = targetLine?.curveCorrection ?? curveCorrection;
const correctionWidth = (firstWidth * targetCurve) / 100;
const firstDasharray = firstWidth + correctionWidth;
const targetRestStroke = targetLine?.strokeDasharray || restStroke;
const gapDashWidth = targetRestStroke?.[0] + targetRestStroke?.[1] || 1;
const restDasharrayLength = dashedWidth / gapDashWidth;
const restDasharray = new Array(Math.ceil(restDasharrayLength)).fill(
targetRestStroke.join(' '),
);
const strokeDasharray = `${firstDasharray} ${restDasharray.join(' ')}`;
const lineStrokeDasharray = { name, strokeDasharray };
const dasharrayIndex = linesStrokeDasharray.current.findIndex((d) => {
return d.name === line?.item?.props?.dataKey;
});
if (dasharrayIndex === -1) {
linesStrokeDasharray.current.push(lineStrokeDasharray);
return;
}
linesStrokeDasharray.current[dasharrayIndex] = lineStrokeDasharray;
});
return null;
},
[dotIndex],
);
return [calculateStrokeDasharray, linesStrokeDasharray.current];
}
export function useDashedStroke(options: UseStrokeDasharrayProps = {}) {
const [calcStrokeDasharray, strokes] = useStrokeDasharray(options);
const [strokeDasharray, setStrokeDasharray] = useState([...strokes]);
const handleAnimationEnd = () => setStrokeDasharray([...strokes]);
const getStrokeDasharray = (name: string) => {
return strokeDasharray.find((s) => s?.name === name)?.strokeDasharray;
};
return {
calcStrokeDasharray,
getStrokeDasharray,
handleAnimationEnd,
};
}

View File

@@ -0,0 +1,14 @@
import debounce from 'debounce';
import { useEffect } from 'react';
export function useDebounceFn<T>(fn: T, ms = 500): T {
const debouncedFn = debounce(fn as any, ms);
useEffect(() => {
return () => {
debouncedFn.clear();
};
});
return debouncedFn as T;
}

View File

@@ -0,0 +1,30 @@
import debounce from 'debounce';
import { useEffect, useMemo, useState } from 'react';
interface DebouncedState<T> {
value: T;
debounced: T;
set: React.Dispatch<React.SetStateAction<T>>;
}
export function useDebounceState<T>(
initialValue: T,
delay = 500,
options?: Parameters<typeof debounce>[2],
): DebouncedState<T> {
const [value, setValue] = useState<T>(initialValue);
const [debouncedValue, _setDebouncedValue] = useState<T>(initialValue);
const setDebouncedValue = useMemo(
() => debounce(_setDebouncedValue, delay, options),
[],
);
useEffect(() => {
setDebouncedValue(value);
}, [value]);
return {
value,
debounced: debouncedValue,
set: setValue,
};
}

View File

@@ -0,0 +1,10 @@
import { useEffect, useState } from 'react';
export const useDebounceValue = <T>(value: T, delay: number): T => {
const [state, setState] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setState(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return state;
};

View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { useCallbackRef } from '@/hooks/use-callback-ref';
export function useDebouncedCallback<T extends (...args: never[]) => unknown>(
callback: T,
delay: number,
) {
const handleCallback = useCallbackRef(callback);
const debounceTimerRef = React.useRef(0);
React.useEffect(
() => () => window.clearTimeout(debounceTimerRef.current),
[],
);
const setValue = React.useCallback(
(...args: Parameters<T>) => {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = window.setTimeout(
() => handleCallback(...args),
delay,
);
},
[handleCallback, delay],
);
return setValue;
}

View File

@@ -0,0 +1,13 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
export function useEventNames(params: any) {
const trpc = useTRPC();
const query = useQuery(
trpc.chart.events.queryOptions(params, {
enabled: !!params.projectId,
staleTime: 1000 * 60 * 10,
}),
);
return query.data ?? [];
}

View File

@@ -0,0 +1,21 @@
import { useTRPC } from '@/integrations/trpc/react';
import type { RouterInputs } from '@/trpc/client';
import { useQuery } from '@tanstack/react-query';
export function useEventProperties(
params: RouterInputs['chart']['properties'],
options?: {
enabled: boolean;
},
) {
const trpc = useTRPC();
const query = useQuery(
trpc.chart.properties.queryOptions(params, {
enabled:
!!params.projectId && typeof options?.enabled !== 'undefined'
? options.enabled
: true,
}),
);
return query.data ?? [];
}

View File

@@ -0,0 +1,127 @@
import type { Options as NuqsOptions } from 'nuqs';
import {
createParser,
parseAsArrayOf,
parseAsString,
useQueryState,
} from 'nuqs';
import { useCallback } from 'react';
import type { IChartEventFilterOperator } from '@openpanel/validation';
const nuqsOptions = { history: 'push' } as const;
export const eventQueryFiltersParser = createParser({
parse: (query: string) => {
if (query === '') return [];
const filters = query.split(';');
return (
filters.map((filter) => {
const [key, operator, value] = filter.split(',');
return {
id: key!,
name: key!,
operator: (operator ?? 'is') as IChartEventFilterOperator,
value: value
? value.split('|').map((v) => decodeURIComponent(v))
: [],
};
}) ?? []
);
},
serialize: (value) => {
return value
.map(
(filter) =>
`${filter.id},${filter.operator},${filter.value.map((v) => encodeURIComponent(v.trim())).join('|')}`,
)
.join(';');
},
});
export function useEventQueryFilters(options: NuqsOptions = {}) {
const [filters, setFilters] = useQueryState(
'f',
eventQueryFiltersParser.withDefault([]).withOptions({
...nuqsOptions,
...options,
}),
);
const setFilter = useCallback(
(
name: string,
value:
| string
| number
| boolean
| undefined
| null
| (string | number | boolean | undefined | null)[],
operator?: IChartEventFilterOperator,
) => {
setFilters((prev) => {
const exists = prev.find((filter) => filter.name === name);
const arrValue = Array.isArray(value) ? value : [value];
const newValue = value ? arrValue.map(String) : [];
// If nothing changes remove it
if (
newValue.length === 0 &&
exists?.value.length === 0 &&
exists.operator === operator
) {
return prev.filter((filter) => filter.name !== name);
}
if (exists) {
return prev.map((filter) => {
if (filter.name === name) {
return {
...filter,
operator:
!operator && newValue.length === 0
? 'isNull'
: (operator ?? 'is'),
value: newValue,
};
}
return filter;
});
}
return [
...prev,
{
id: name,
name,
operator:
!operator && newValue.length === 0
? 'isNull'
: (operator ?? 'is'),
value: newValue,
},
];
});
},
[setFilters],
);
const removeFilter = useCallback(
(name: string) => {
setFilters((prev) => prev.filter((filter) => filter.name !== name));
},
[setFilters],
);
return [filters, setFilter, setFilters, removeFilter] as const;
}
export const eventQueryNamesFilter = parseAsArrayOf(parseAsString).withDefault(
[],
);
export function useEventQueryNamesFilter(options: NuqsOptions = {}) {
return useQueryState('events', eventQueryNamesFilter.withOptions(options));
}

View File

@@ -0,0 +1,44 @@
import type { IInterval } from '@openpanel/validation';
export function formatDateInterval(interval: IInterval, date: Date): string {
try {
if (interval === 'hour' || interval === 'minute') {
return new Intl.DateTimeFormat('en-GB', {
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
if (interval === 'month') {
return new Intl.DateTimeFormat('en-GB', { month: 'short' }).format(date);
}
if (interval === 'week') {
return new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
}).format(date);
}
if (interval === 'day') {
return new Intl.DateTimeFormat('en-GB', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
}).format(date);
}
return date.toISOString();
} catch (e) {
return '';
}
}
export function useFormatDateInterval(interval: IInterval) {
return (date: Date | string) =>
formatDateInterval(
interval,
typeof date === 'string' ? new Date(date) : date,
);
}

View File

@@ -0,0 +1,14 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation } from '@tanstack/react-query';
export function useLogout() {
const trpc = useTRPC();
const signOut = useMutation(
trpc.auth.signOut.mutationOptions({
onSuccess() {
window.location.href = '/';
},
}),
);
return signOut;
}

View File

@@ -0,0 +1,80 @@
import { round } from '@/utils/math';
import { isNil } from 'ramda';
export function fancyMinutes(time: number) {
const minutes = Math.floor(time / 60);
if (minutes > 60) {
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
const seconds = round(time - minutes * 60, 0);
if (minutes === 0) return `${seconds}s`;
return `${minutes}m ${seconds}s`;
}
export const formatNumber =
(locale: string) => (value: number | null | undefined) => {
if (isNil(value)) {
return 'N/A';
}
return new Intl.NumberFormat(locale).format(value);
};
export const shortNumber =
(locale: string) => (value: number | null | undefined) => {
if (isNil(value)) {
return 'N/A';
}
return new Intl.NumberFormat(locale, {
notation: 'compact',
}).format(value);
};
export const formatCurrency =
(locale: string) =>
(amount: number, currency = 'USD') => {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: currency,
minimumFractionDigits: 0,
maximumFractionDigits: 1,
}).format(amount);
};
export function useNumber() {
const locale = 'en-US';
const format = formatNumber(locale);
const short = shortNumber(locale);
const currency = formatCurrency(locale);
return {
currency,
format,
short,
shortWithUnit: (value: number | null | undefined, unit?: string | null) => {
if (isNil(value)) {
return 'N/A';
}
if (unit === 'min') {
return fancyMinutes(value);
}
return `${short(value)}${unit ? ` ${unit}` : ''}`;
},
formatWithUnit: (
value: number | null | undefined,
unit?: string | null,
) => {
if (isNil(value)) {
return 'N/A';
}
if (unit === 'min') {
return fancyMinutes(value);
}
if (unit === '%') {
return `${format(round(value * 100, 1))}${unit ? ` ${unit}` : ''}`;
}
return `${format(value)}${unit ? ` ${unit}` : ''}`;
},
};
}

View File

@@ -0,0 +1,27 @@
import { useLocation } from '@tanstack/react-router';
export function usePageTabs(tabs: { id: string; label: string }[]) {
const location = useLocation();
const tab = location.pathname.split('/').pop();
if (!tab) {
return {
activeTab: tabs[0].id,
tabs,
};
}
const match = tabs.find((t) => t.id === tab);
if (!match) {
return {
activeTab: tabs[0].id,
tabs,
};
}
return {
activeTab: match.id,
tabs,
};
}

View File

@@ -0,0 +1,12 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
export function useProfileProperties(projectId: string) {
const trpc = useTRPC();
const query = useQuery(
trpc.profile.properties.queryOptions({
projectId,
}),
);
return query.data ?? [];
}

View File

@@ -0,0 +1,13 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
export function useProfileValues(projectId: string, property: string) {
const trpc = useTRPC();
const query = useQuery(
trpc.profile.values.queryOptions({
projectId: projectId,
property,
}),
);
return query.data?.values ?? [];
}

View File

@@ -0,0 +1,12 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useQuery } from '@tanstack/react-query';
export function usePropertyValues(params: any) {
const trpc = useTRPC();
const query = useQuery(
trpc.chart.values.queryOptions(params, {
enabled: !!params.projectId,
}),
);
return query.data?.values ?? [];
}

View File

@@ -0,0 +1,54 @@
import type { IChartData } from '@/trpc/client';
import { getChartColor } from '@/utils/theme';
import { useMemo } from 'react';
export type IRechartPayloadItem = {
id: string;
names: string[];
color: string;
event: { id?: string; name: string };
count: number;
date: string;
previous?: {
value: number;
diff: number | null;
state: 'positive' | 'negative' | 'neutral';
};
};
export function useRechartDataModel(series: IChartData['series']) {
return useMemo(() => {
return (
series[0]?.data.map(({ date }) => {
return {
date,
timestamp: new Date(date).getTime(),
...series.reduce((acc, serie, idx) => {
return {
...acc,
...serie.data.reduce(
(acc2, item) => {
if (item.date === date) {
if (item.previous) {
acc2[`${serie.id}:prev:count`] = item.previous.value;
}
acc2[`${serie.id}:count`] = item.count;
acc2[`${serie.id}:payload`] = {
...item,
id: serie.id,
event: serie.event,
names: serie.names,
color: getChartColor(idx),
} satisfies IRechartPayloadItem;
}
return acc2;
},
{} as Record<string, any>,
),
};
}, {}),
};
}) ?? []
);
}, [series]);
}

View File

@@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, useState } from 'react';
export const useScrollAnchor = () => {
const messagesRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const visibilityRef = useRef<HTMLDivElement>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
const [isVisible, setIsVisible] = useState(false);
const scrollToBottom = useCallback(() => {
if (messagesRef.current) {
messagesRef.current.scrollIntoView({
block: 'end',
behavior: 'smooth',
});
}
}, []);
useEffect(() => {
if (messagesRef.current) {
if (isAtBottom && !isVisible) {
messagesRef.current.scrollIntoView({
block: 'end',
});
}
}
}, [isAtBottom, isVisible]);
useEffect(() => {
const { current } = scrollRef;
if (current) {
const handleScroll = (event: Event) => {
const target = event.target as HTMLDivElement;
const offset = 20;
const isAtBottom =
target.scrollTop + target.clientHeight >=
target.scrollHeight - offset;
setIsAtBottom(isAtBottom);
};
current.addEventListener('scroll', handleScroll, {
passive: true,
});
return () => {
current.removeEventListener('scroll', handleScroll);
};
}
}, []);
useEffect(() => {
if (visibilityRef.current) {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setIsVisible(true);
} else {
setIsVisible(false);
}
});
});
observer.observe(visibilityRef.current);
return () => {
observer.disconnect();
};
}
});
return {
messagesRef,
scrollRef,
visibilityRef,
scrollToBottom,
isAtBottom,
isVisible,
};
};

View File

@@ -0,0 +1,32 @@
import { useDataTablePagination } from '@/components/ui/data-table/data-table-hooks';
import { useQueryState } from 'nuqs';
import { useDebounceValue } from './use-debounce-value';
export function useSearchQueryState(props?: {
searchKey?: string;
debounceMs?: number;
}) {
const searchKey = props?.searchKey ?? 'search';
const debounceMs = props?.debounceMs ?? 500;
const { page, setPage } = useDataTablePagination();
const [search, setSearch] = useQueryState(searchKey, {
defaultValue: '',
clearOnDefault: true,
limitUrlUpdates: {
method: 'debounce',
timeMs: debounceMs,
},
});
const debouncedSearch = useDebounceValue(search, debounceMs);
return {
search,
debouncedSearch,
setSearch: (value: string) => {
if (page !== 1) {
void setPage(1);
}
setSearch(value);
},
};
}

View File

@@ -0,0 +1,35 @@
import { useTRPC } from '@/integrations/trpc/react';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useRouteContext, useRouter } from '@tanstack/react-router';
import { useEffect, useRef } from 'react';
export function useSessionExtension() {
const trpc = useTRPC();
const context = useRouteContext({
strict: false,
});
const extendMutation = useMutation(trpc.auth.extendSession.mutationOptions());
const intervalRef = useRef<NodeJS.Timeout>(null);
const session = context.session?.session;
useEffect(() => {
if (!session) return;
const extendSessionFn = () => extendMutation.mutate();
intervalRef.current = setInterval(
() => {
extendMutation.mutate();
},
1000 * 60 * 5,
);
extendSessionFn();
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, [session]);
}

View File

@@ -0,0 +1 @@
export { useTheme } from '@/components/theme-provider';

View File

@@ -0,0 +1,15 @@
import throttle from 'lodash.throttle';
import { useCallback, useEffect, useRef } from 'react';
export function useThrottle(cb: () => void, delay: number) {
const options = { leading: true, trailing: false }; // add custom lodash options
const cbRef = useRef(cb);
// use mutable ref to make useCallback/throttle not depend on `cb` dep
useEffect(() => {
cbRef.current = cb;
});
return useCallback(
throttle(() => cbRef.current(), delay, options),
[delay],
);
}

View File

@@ -0,0 +1,28 @@
import type { IChartData } from '@/trpc/client';
import { useEffect, useMemo, useState } from 'react';
export type IVisibleSeries = ReturnType<typeof useVisibleSeries>['series'];
export function useVisibleSeries(data: IChartData, limit?: number | undefined) {
const max = limit ?? 5;
const [visibleSeries, setVisibleSeries] = useState<string[]>(
data?.series?.slice(0, max).map((serie) => serie.id) ?? [],
);
useEffect(() => {
setVisibleSeries(
data?.series?.slice(0, max).map((serie) => serie.id) ?? [],
);
}, [data, max]);
return useMemo(() => {
return {
series: data.series
.map((serie, index) => ({
...serie,
index,
}))
.filter((serie) => visibleSeries.includes(serie.id)),
setVisibleSeries,
} as const;
}, [visibleSeries, data.series]);
}

View File

@@ -0,0 +1,49 @@
import debounce from 'lodash.debounce';
import { useEffect, useMemo, useState } from 'react';
import { useWebSocket } from 'react-use-websocket/dist/lib/use-websocket';
import { getSuperJson } from '@openpanel/json';
import { useAppContext } from './use-app-context';
type UseWSOptions = {
debounce?: {
delay: number;
maxWait?: number;
};
};
export default function useWS<T>(
path: string,
onMessage: (event: T) => void,
options?: UseWSOptions,
) {
const context = useAppContext();
const ws = context.apiUrl.replace(/^https/, 'wss').replace(/^http/, 'ws');
const [baseUrl, setBaseUrl] = useState(`${ws}${path}`);
const debouncedOnMessage = useMemo(() => {
if (options?.debounce) {
return debounce(onMessage, options.debounce.delay, options.debounce);
}
return onMessage;
}, [options?.debounce?.delay]);
useEffect(() => {
if (baseUrl === `${ws}${path}`) return;
setBaseUrl(`${ws}${path}`);
}, [path, baseUrl, ws]);
useWebSocket(baseUrl, {
shouldReconnect: () => true,
onMessage(event) {
try {
const data = getSuperJson<T>(event.data);
if (data !== null && data !== undefined) {
debouncedOnMessage(data);
}
} catch (error) {
console.error('Error parsing message', error);
}
},
});
}