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:
committed by
GitHub
parent
436e81ecc9
commit
81a7e5d62e
16
apps/start/src/hooks/use-app-context.ts
Normal file
16
apps/start/src/hooks/use-app-context.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
11
apps/start/src/hooks/use-app-params.ts
Normal file
11
apps/start/src/hooks/use-app-params.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
32
apps/start/src/hooks/use-breakpoint.ts
Normal file
32
apps/start/src/hooks/use-breakpoint.ts
Normal 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>;
|
||||
}
|
||||
27
apps/start/src/hooks/use-callback-ref.ts
Normal file
27
apps/start/src/hooks/use-callback-ref.ts
Normal 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 };
|
||||
23
apps/start/src/hooks/use-client-secret.ts
Normal file
23
apps/start/src/hooks/use-client-secret.ts
Normal 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;
|
||||
}
|
||||
199
apps/start/src/hooks/use-dashed-stroke.tsx
Normal file
199
apps/start/src/hooks/use-dashed-stroke.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
14
apps/start/src/hooks/use-debounce-fn.ts
Normal file
14
apps/start/src/hooks/use-debounce-fn.ts
Normal 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;
|
||||
}
|
||||
30
apps/start/src/hooks/use-debounce-state.ts
Normal file
30
apps/start/src/hooks/use-debounce-state.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
10
apps/start/src/hooks/use-debounce-value.ts
Normal file
10
apps/start/src/hooks/use-debounce-value.ts
Normal 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;
|
||||
};
|
||||
28
apps/start/src/hooks/use-debounced-callback.ts
Normal file
28
apps/start/src/hooks/use-debounced-callback.ts
Normal 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;
|
||||
}
|
||||
13
apps/start/src/hooks/use-event-names.ts
Normal file
13
apps/start/src/hooks/use-event-names.ts
Normal 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 ?? [];
|
||||
}
|
||||
21
apps/start/src/hooks/use-event-properties.ts
Normal file
21
apps/start/src/hooks/use-event-properties.ts
Normal 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 ?? [];
|
||||
}
|
||||
127
apps/start/src/hooks/use-event-query-filters.ts
Normal file
127
apps/start/src/hooks/use-event-query-filters.ts
Normal 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));
|
||||
}
|
||||
44
apps/start/src/hooks/use-format-date-interval.ts
Normal file
44
apps/start/src/hooks/use-format-date-interval.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
14
apps/start/src/hooks/use-logout.ts
Normal file
14
apps/start/src/hooks/use-logout.ts
Normal 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;
|
||||
}
|
||||
80
apps/start/src/hooks/use-numer-formatter.ts
Normal file
80
apps/start/src/hooks/use-numer-formatter.ts
Normal 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}` : ''}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
27
apps/start/src/hooks/use-page-tabs.ts
Normal file
27
apps/start/src/hooks/use-page-tabs.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
12
apps/start/src/hooks/use-profile-properties.ts
Normal file
12
apps/start/src/hooks/use-profile-properties.ts
Normal 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 ?? [];
|
||||
}
|
||||
13
apps/start/src/hooks/use-profile-values.ts
Normal file
13
apps/start/src/hooks/use-profile-values.ts
Normal 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 ?? [];
|
||||
}
|
||||
12
apps/start/src/hooks/use-property-values.ts
Normal file
12
apps/start/src/hooks/use-property-values.ts
Normal 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 ?? [];
|
||||
}
|
||||
54
apps/start/src/hooks/use-rechart-data-model.ts
Normal file
54
apps/start/src/hooks/use-rechart-data-model.ts
Normal 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]);
|
||||
}
|
||||
82
apps/start/src/hooks/use-scroll-anchor.ts
Normal file
82
apps/start/src/hooks/use-scroll-anchor.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
32
apps/start/src/hooks/use-search-query-state.ts
Normal file
32
apps/start/src/hooks/use-search-query-state.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
35
apps/start/src/hooks/use-session-extension.ts
Normal file
35
apps/start/src/hooks/use-session-extension.ts
Normal 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]);
|
||||
}
|
||||
1
apps/start/src/hooks/use-theme.ts
Normal file
1
apps/start/src/hooks/use-theme.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { useTheme } from '@/components/theme-provider';
|
||||
15
apps/start/src/hooks/use-throttle.ts
Normal file
15
apps/start/src/hooks/use-throttle.ts
Normal 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],
|
||||
);
|
||||
}
|
||||
28
apps/start/src/hooks/use-visible-series.ts
Normal file
28
apps/start/src/hooks/use-visible-series.ts
Normal 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]);
|
||||
}
|
||||
49
apps/start/src/hooks/use-ws.ts
Normal file
49
apps/start/src/hooks/use-ws.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user