Merge branch 'staging' into big-nate-bus-1939-quarters-are-still-not-being-displayed-correctly

This commit is contained in:
Nate Kelley 2025-09-25 12:27:23 -06:00
commit be16261859
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
42 changed files with 281 additions and 171 deletions

View File

@ -127,6 +127,7 @@
"@tiptap/react": "^3.5.0",
"@tiptap/starter-kit": "^3.5.0",
"@tiptap/suggestion": "^3.5.0",
"@types/qs": "^6.14.0",
"@udecode/cn": "^49.0.15",
"@uploadthing/react": "^7.3.3",
"axios": "catalog:",
@ -157,6 +158,7 @@
"platejs": "49.2.21",
"pluralize": "^8.0.0",
"posthog-js": "^1.268.1",
"qs": "^6.14.0",
"react": "catalog:",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1",

View File

@ -7,7 +7,6 @@ export type * from './datasources';
export type * from './metric';
export type * from './permission';
export type * from './permission_groups';
export type * from './search';
export type * from './sql';
export type * from './teams';
export type * from './terms';

View File

@ -1 +0,0 @@
export type * from './interfaces';

View File

@ -1,10 +0,0 @@
import type { ShareAssetType } from '@buster/server-shared/share';
export interface BusterSearchResult {
id: string;
highlights: string[];
name: string;
updated_at: string;
type: ShareAssetType;
score: number;
}

View File

@ -1,3 +1,4 @@
export * from './queryRequests';
export * from './queryRequestsV2';
export * from './requests';
export * from './requestsV2';

View File

@ -26,7 +26,6 @@ import {
deleteChat,
duplicateChat,
getChat,
getListChats,
getListLogs,
shareChat,
unshareChat,
@ -34,12 +33,13 @@ import {
updateChatMessageFeedback,
updateChatShare,
} from './requests';
import { getListChats } from './requestsV2';
export const useGetListChats = (
filters?: Omit<Parameters<typeof getListChats>[0], 'page_token' | 'page_size'>
filters?: Omit<Parameters<typeof getListChats>[0], 'page' | 'page_size'>
) => {
const filtersCompiled: Parameters<typeof getListChats>[0] = useMemo(
() => ({ admin_view: false, page_token: 0, page_size: 5000, ...filters }),
() => ({ admin_view: false, page: 1, page_size: 5000, ...filters }),
[filters]
);

View File

@ -1,12 +1,17 @@
import type { ChatListItem } from '@buster/server-shared/chats';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { mainApi } from '../instances';
import { mainApiV2 } from '../instances';
// Mock the mainApi
// Mock the mainApi and mainApiV2
vi.mock('../instances', () => ({
mainApi: {
get: vi.fn(),
},
mainApiV2: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}));
describe('Chat API Requests', () => {
@ -36,17 +41,19 @@ describe('Chat API Requests', () => {
];
// Setup mock response
(mainApi.get as any).mockResolvedValueOnce({ data: mockChats });
(mainApiV2.get as any).mockResolvedValueOnce({
data: { data: mockChats }
});
// Import the function we want to test
const { getListChats } = await import('./requests');
const { getListChats } = await import('./requestsV2');
// Execute the function
const result = await getListChats();
// Verify the API was called with correct parameters
expect(mainApi.get).toHaveBeenCalledWith('/chats', {
params: { page_token: 0, page_size: 3500 },
expect(mainApiV2.get).toHaveBeenCalledWith('/chats', {
params: { page: 1, page_size: 3500 },
});
// Verify the result matches the mock data

View File

@ -4,8 +4,6 @@ import type {
DuplicateChatResponse,
GetChatRequest,
GetChatResponse,
GetChatsListRequest,
GetChatsListResponse,
GetLogsListRequest,
GetLogsListResponse,
ShareChatResponse,
@ -23,16 +21,6 @@ import { mainApi, mainApiV2 } from '../instances';
const CHATS_BASE = '/chats';
// Client-side fetch version
export const getListChats = async (params?: GetChatsListRequest): Promise<GetChatsListResponse> => {
const { page_token = 0, page_size = 3500 } = params || {};
return mainApi
.get<GetChatsListResponse>(`${CHATS_BASE}`, {
params: { page_token, page_size },
})
.then((res) => res.data);
};
export const getListLogs = async (params?: GetLogsListRequest): Promise<GetLogsListResponse> => {
const { page_token = 0, page_size = 3500 } = params || {};
return mainApi

View File

@ -1,6 +1,22 @@
import type { ChatCreateRequest, ChatWithMessages } from '@buster/server-shared/chats';
import type {
ChatCreateRequest,
ChatWithMessages,
GetChatsListResponseV2,
GetChatsRequestV2,
} from '@buster/server-shared/chats';
import mainApi, { mainApiV2 } from '../instances';
export const getListChats = async (
params?: GetChatsRequestV2
): Promise<GetChatsListResponseV2['data']> => {
const { page = 1, page_size = 3500 } = params || {};
return mainApiV2
.get<GetChatsListResponseV2>(`/chats`, {
params: { page, page_size },
})
.then((res) => res.data.data);
};
export const createNewChat = async (props: ChatCreateRequest) => {
return mainApiV2.post<ChatWithMessages>('/chats', props).then((res) => res.data);
};

View File

@ -2,7 +2,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { create } from 'mutative';
import { dashboardQueryKeys } from '@/api/query_keys/dashboard';
import { getOriginalDashboard } from '@/context/Dashboards/useOriginalDashboardStore';
import type { dashboardsUpdateDashboard } from '../requests';
import { useSaveDashboard } from './useSaveDashboard';
/**

View File

@ -32,7 +32,7 @@ export const getDashboardById = async ({
/** The version number of the dashboard */
version_number?: number;
}) => {
return await mainApiV2
return await mainApi
.get<GetDashboardResponse>(`/dashboards/${id}`, {
params: { password, version_number },
})

View File

@ -83,7 +83,9 @@ export const useGetMetric = <TData = BusterMetric>(
const { isFetched: isFetchedInitial, isError: isErrorInitial } = useQuery({
...metricsQueryKeys.metricsGetMetric(id || '', 'LATEST'),
queryFn: () => getMetricQueryFn({ id, version: 'LATEST', queryClient, password }),
queryFn: () => {
return getMetricQueryFn({ id, version: 'LATEST', queryClient, password });
},
retry(_failureCount, error) {
if (error?.message !== undefined && id) {
setProtectedAssetPasswordError({
@ -93,16 +95,19 @@ export const useGetMetric = <TData = BusterMetric>(
}
return false;
},
enabled: (params?.enabled ?? true) && !!id,
select: undefined,
...params,
enabled: (params?.enabled ?? true) && !!id,
});
return useQuery({
...metricsQueryKeys.metricsGetMetric(id || '', selectedVersionNumber),
enabled: !!id && !!latestVersionNumber && isFetchedInitial && !isErrorInitial,
queryFn: () => getMetricQueryFn({ id, version: selectedVersionNumber, queryClient, password }),
queryFn: () => {
return getMetricQueryFn({ id, version: selectedVersionNumber, queryClient, password });
},
...params,
select: params?.select,
enabled: !!id && !!latestVersionNumber && isFetchedInitial && !isErrorInitial,
});
};

View File

@ -31,9 +31,9 @@ export const getMetric = async ({
id,
...params
}: GetMetricParams & GetMetricQuery): Promise<GetMetricResponse> => {
return mainApiV2.get<GetMetricResponse>(`/metric_files/${id}`, { params }).then((res) => {
return res.data;
});
return mainApiV2
.get<GetMetricResponse>(`/metric_files/${id}`, { params })
.then(({ data }) => data);
};
export const getMetricData = async ({

View File

@ -1,15 +1,22 @@
import type { SearchTextResponse } from '@buster/server-shared/search';
import { keepPreviousData, type UseQueryOptions, useQuery } from '@tanstack/react-query';
import type { RustApiError } from '@/api/errors';
import { searchQueryKeys } from '@/api/query_keys/search';
import { search } from './requests';
export const useSearch = (
export const useSearch = <T = SearchTextResponse>(
params: Parameters<typeof search>[0],
options?: Omit<UseQueryOptions<Awaited<ReturnType<typeof search>>>, 'queryKey' | 'queryFn'>
options?: Omit<UseQueryOptions<SearchTextResponse, RustApiError, T>, 'queryKey' | 'queryFn'>,
postQueryOptions?: {
doNotUnwrapData?: boolean;
}
) => {
return useQuery({
const { doNotUnwrapData = false } = postQueryOptions || {};
return useQuery<SearchTextResponse, RustApiError, T>({
...searchQueryKeys.getSearchResult(params),
queryFn: () => search(params),
placeholderData: keepPreviousData,
select: options?.select,
...options,
placeholderData: keepPreviousData,
});
};

View File

@ -1,11 +1,7 @@
import type { AssetType } from '@buster/server-shared/assets';
import type { BusterSearchResult } from '@/api/asset_interfaces/search';
import { mainApi } from '../instances';
import type { SearchTextRequest, SearchTextResponse } from '@buster/server-shared/search';
import qs from 'qs';
import { mainApiV2 } from '../instances';
export const search = async (params: {
query: string;
asset_types: Extract<AssetType, 'dashboard_file' | 'metric_file' | 'collection'>[];
num_results?: number;
}) => {
return mainApi.post<BusterSearchResult[]>('/search', params).then((res) => res.data);
export const search = async (params: SearchTextRequest) => {
return mainApiV2.get<SearchTextResponse>('/search', { params }).then((res) => res.data);
};

View File

@ -1,6 +1,7 @@
import { isServer } from '@tanstack/react-query';
import type { AxiosRequestHeaders } from 'axios';
import axios, { type AxiosError, type InternalAxiosRequestConfig } from 'axios';
import qs from 'qs';
import { getSupabaseSession } from '@/integrations/supabase/getSupabaseUserClient';
import { Route as AuthRoute } from '@/routes/auth.login';
import { BASE_URL_V2 } from './config';
@ -15,6 +16,9 @@ export const createAxiosInstance = (baseURL = BASE_URL_V2) => {
headers: {
'Content-Type': 'application/json',
},
paramsSerializer: (params) => {
return qs.stringify(params, { arrayFormat: 'repeat' }); //💰
},
});
// Response interceptor with retry logic for auth errors

View File

@ -2,7 +2,8 @@ import type { ChatListItem } from '@buster/server-shared/chats';
import { queryOptions } from '@tanstack/react-query';
import type { BusterChatMessage, IBusterChat } from '@/api/asset_interfaces/chat';
import type { BusterMetricData } from '@/api/asset_interfaces/metric/metricDataInterfaces';
import type { getListChats, getListLogs } from '@/api/buster_rest/chats/requests';
import type { getListLogs } from '@/api/buster_rest/chats/requests';
import type { getListChats } from '@/api/buster_rest/chats/requestsV2';
const chatsGetChat = (chatId: string) =>
queryOptions<IBusterChat>({
@ -25,9 +26,7 @@ const chatsMessagesFetchingData = (messageId: string) =>
enabled: !!messageId,
});
const chatsGetList = (
filters?: Omit<Parameters<typeof getListChats>[0], 'page_token' | 'page_size'>
) =>
const chatsGetList = (filters?: Omit<Parameters<typeof getListChats>[0], 'page' | 'page_size'>) =>
queryOptions<ChatListItem[]>({
queryKey: [
'chats',

View File

@ -1,9 +1,8 @@
import type { SearchTextRequest, SearchTextResponse } from '@buster/server-shared';
import { queryOptions } from '@tanstack/react-query';
import type { BusterSearchResult } from '@/api/asset_interfaces/search';
import type { search } from '../buster_rest/search';
export const getSearchResult = (params: Parameters<typeof search>[0]) =>
queryOptions<BusterSearchResult[]>({
export const getSearchResult = (params: SearchTextRequest) =>
queryOptions<SearchTextResponse>({
queryKey: ['search', 'results', params] as const,
staleTime: 1000 * 15, // 15 seconds,
});

View File

@ -1,6 +1,6 @@
import type { SearchTextResponse } from '@buster/server-shared/search';
import type { ShareAssetType } from '@buster/server-shared/share';
import React, { useLayoutEffect, useMemo, useState } from 'react';
import type { BusterSearchResult } from '@/api/asset_interfaces/search';
import {
useAddAndRemoveAssetsFromCollection,
useGetCollection,
@ -33,8 +33,9 @@ export const AddToCollectionModal: React.FC<{
const { data: searchResults } = useSearch({
query: debouncedSearchTerm,
asset_types: ['metric_file', 'dashboard_file'],
num_results: 100,
assetTypes: ['metric_file', 'dashboard_file', 'report_file'],
page_size: 50,
page: 1,
});
const [selectedAssets, setSelectedAssets] = useState<SelectedAsset[]>([]);
@ -42,13 +43,13 @@ export const AddToCollectionModal: React.FC<{
return selectedAssets.map((asset) => asset.id);
}, [selectedAssets]);
const columns = useMemo<InputSelectModalProps<BusterSearchResult>['columns']>(
const columns = useMemo<InputSelectModalProps<SearchTextResponse['data'][number]>['columns']>(
() => [
{
title: 'Name',
dataIndex: 'name',
render: (name, data) => {
const Icon = assetTypeToIcon(data.type) || ASSET_ICONS.metrics;
dataIndex: 'title',
render: (name, record) => {
const Icon = assetTypeToIcon(record.assetType) || ASSET_ICONS.metrics;
return (
<div className="flex items-center gap-1.5">
<span className="text-icon-color">
@ -61,7 +62,7 @@ export const AddToCollectionModal: React.FC<{
},
{
title: 'Updated',
dataIndex: 'updated_at',
dataIndex: 'updatedAt',
width: 140,
render: (value: string) => {
return formatDate({
@ -74,10 +75,10 @@ export const AddToCollectionModal: React.FC<{
[]
);
const rows: BusterListRowItem<BusterSearchResult>[] = useMemo(() => {
const rows: BusterListRowItem<SearchTextResponse['data'][number]>[] = useMemo(() => {
return (
searchResults?.map((asset) => ({
id: asset.id,
searchResults?.data?.map((asset) => ({
id: asset.assetId,
data: asset,
})) || []
);
@ -86,7 +87,7 @@ export const AddToCollectionModal: React.FC<{
const createKeySearchResultMap = useMemoizedFn(() => {
const map = new Map<string, SelectedAsset>();
rows.forEach((asset) => {
if (asset.data?.type) map.set(asset.id, { type: asset.data?.type, id: asset.id });
if (asset.data?.assetType) map.set(asset.id, { type: asset.data?.assetType, id: asset.id });
});
return map;
});

View File

@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { BusterSearchResult } from '@/api/asset_interfaces/search';
import type { SearchTextRequest, SearchTextResponse } from '@buster/server-shared/search';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useSearch } from '@/api/buster_rest/search';
import { Button } from '@/components/ui/buttons';
import {
@ -9,6 +9,7 @@ import {
import { useDebounce } from '@/hooks/useDebounce';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
import { formatDate } from '@/lib/date';
import { assetTypeToIcon } from '../icons/assetIcons';
export const AddMetricModal: React.FC<{
open: boolean;
@ -46,21 +47,34 @@ export const AddMetricModal: React.FC<{
const { data: searchResults } = useSearch(
{
query: debouncedSearchTerm,
asset_types: ['metric_file'],
num_results: 100,
},
{ enabled: true }
assetTypes: ['metric_file'],
page_size: 25,
page: 1,
} satisfies SearchTextRequest,
{ enabled: true, select: useCallback((data: SearchTextResponse) => data.data, []) }
);
const columns = useMemo<InputSelectModalProps<BusterSearchResult>['columns']>(
const columns = useMemo<InputSelectModalProps<SearchTextResponse['data'][number]>['columns']>(
() => [
{
title: 'Name',
dataIndex: 'name',
title: 'Title',
dataIndex: 'title',
render: (value, record) => {
const Icon = assetTypeToIcon(record.assetType);
return (
<div className="flex items-center gap-1.5">
<span className="text-icon-color">
<Icon />
</span>
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: this endpoint is sanitized */}
<span dangerouslySetInnerHTML={{ __html: value }}></span>
</div>
);
},
},
{
title: 'Updated',
dataIndex: 'updated_at',
title: 'Updated at',
dataIndex: 'updatedAt',
width: 140,
render: (value) => {
return formatDate({
@ -76,8 +90,8 @@ export const AddMetricModal: React.FC<{
const rows = useMemo(() => {
return (
searchResults?.map((result) => ({
id: result.id,
dataTestId: `item-${result.id}`,
id: result.assetId,
dataTestId: `item-${result.assetId}`,
data: result,
})) || []
);
@ -100,7 +114,7 @@ export const AddMetricModal: React.FC<{
const item = rows.find((row) => row.id === id);
return {
id: id,
name: item?.data?.name || id,
name: item?.data?.title || id,
};
});

View File

@ -11,7 +11,6 @@ import {
SquareChart,
Table,
} from '@/components/ui/icons';
import type { iconProps } from '@/components/ui/icons/NucleoIconOutlined/iconProps';
import SquareChartPlus from '@/components/ui/icons/NucleoIconOutlined/square-chart-plus';
export const ASSET_ICONS = {

View File

@ -16,7 +16,6 @@ export type MetricChartCardProps = {
metricId: string;
versionNumber: number | undefined;
readOnly?: boolean;
className?: string;
attributes?: DraggableAttributes;
listeners?: DraggableSyntheticListeners;
headerSecondaryContent?: React.ReactNode;
@ -25,7 +24,7 @@ export type MetricChartCardProps = {
renderChartContent?: boolean; // we do this to avoid expensive rendering if off screen
disableTooltip?: boolean;
cacheDataId?: string;
};
} & React.HTMLAttributes<HTMLDivElement>;
const stableMetricSelect = ({
chart_config,
@ -62,6 +61,7 @@ export const MetricChartCard = React.memo(
renderChartContent = true,
disableTooltip,
cacheDataId,
...rest
},
ref
) => {
@ -102,6 +102,7 @@ export const MetricChartCard = React.memo(
errorData={errorData}
isTable={isTable}
className={className}
{...rest}
>
<MetricViewChartHeader
name={name}
@ -144,13 +145,12 @@ type MetricViewChartCardContainerProps = {
hasData: boolean;
errorData: boolean;
isTable: boolean;
className?: string;
};
} & React.HTMLAttributes<HTMLDivElement>;
const MetricViewChartCardContainer = React.forwardRef<
HTMLDivElement,
MetricViewChartCardContainerProps
>(({ children, loadingData, hasData, errorData, isTable, className }, ref) => {
>(({ children, loadingData, hasData, errorData, isTable, className, ...divProps }, ref) => {
const cardClass = React.useMemo(() => {
if (loadingData || errorData || !hasData) return 'h-full max-h-[600px]';
if (isTable) return 'h-full';
@ -161,6 +161,7 @@ const MetricViewChartCardContainer = React.forwardRef<
<MetricViewChartProvider>
<div
ref={ref}
{...divProps}
className={cn(
'bg-background flex flex-col overflow-hidden rounded border shadow',
cardClass,

View File

@ -3,6 +3,7 @@ import { MetricChartCard } from '@/components/features/metrics/MetricChartCard';
import { ReportMetricThreeDotMenu } from '@/components/features/metrics/ReportMetricItem';
import { useGetReportParams } from '@/context/Reports/useGetReportParams';
import { useInViewport } from '@/hooks/useInViewport';
import { useMemoizedFn } from '@/hooks/useMemoizedFn';
export const MetricContent = React.memo(
({
@ -30,6 +31,10 @@ export const MetricContent = React.memo(
}
const renderChart = inViewport || isExportMode || hasBeenInViewport.current;
const handleCopy = useMemoizedFn((e: React.ClipboardEvent<HTMLDivElement>) => {
e.stopPropagation();
});
return (
<MetricChartCard
ref={ref}
@ -50,6 +55,7 @@ export const MetricContent = React.memo(
/>
)
}
onCopy={handleCopy}
/>
);
}

View File

@ -53,3 +53,8 @@ p {
th {
@apply text-base font-normal;
}
strong,
b {
@apply font-semibold;
}

View File

@ -239,9 +239,9 @@ You operate in a loop to complete tasks:
- The majority of explanation should go in the report, only use the done-tool to summarize the report and list any potential issues
- Explain major assumptions that could impact the results
- Explain the meaning of calculations that are made in the report or metric
- You should create a metric for all calculations referenced in the report.
- Create a metric object for each key calculation, but combine related metrics into a single visualization when they share the same categorical dimension (use grouped bars or a combo chart with dual axes as needed). Creating multiple metrics does not justify multiple charts in the same section.
- Avoid creating large comprehensive tables that combine multiple metrics; instead, build individual metrics and use comprehensive views only to highlight specific interesting items (e.g., a table showing all data for a few interesting data points).
- You should create a visualization for all calculations referenced in the report.
- Create a metric object (a visualization) for each key calculation, but combine related metrics into a single visualization when they share the same categorical dimension (use grouped bars or a combo chart with dual axes as needed). Creating multiple metrics does not justify multiple charts in the same section.
- Avoid creating large comprehensive tables that combine multiple metrics; instead, build individual visualizations and use comprehensive views only to highlight specific interesting items (e.g., a table showing all data for a few interesting data points).
- You should always have a methodolgy section that explains the data, calculations, decisions, and assumptions made for each metric or definition. You can have a more technical tone in this final section.
- Style Guidelines:
- Use **bold** for key words, phrases, data points, or ideas that should be highlighted.
@ -399,7 +399,7 @@ You operate in a loop to complete tasks:
- Proportions: Prefer bar charts, but pie or donut charts can be used
- Relationships between two variables: Use scatter plots to visualize correlations or patterns
- Multiple data series over time: Use combo charts with multiple y-axes to display different metrics or categories
- Use combo charts only when they clarify relationships between two or more related metrics, especially when the metrics have different scales or units (e.g., "revenue in dollars vs. conversion rate in %").
- Use combo charts only when they clarify relationships between two or more related metrics, especially when the metrics have different scales or units (e.g., "revenue in dollars vs. conversion rate in %").
- Preferred use case: bars for absolute values (totals, counts, amounts) and a line for trends, ratios, or rates.
- Avoid combo charts when all metrics share the same unit/scale or when the relationship between metrics is weak or redundant—use a simpler chart instead.
- Limit to two series/axes whenever possible; adding more can make the chart confusing or visually cluttered.
@ -407,6 +407,13 @@ You operate in a loop to complete tasks:
- Assign the primary metric (larger values or main focus) to the left y-axis.
- Assign the secondary metric (smaller values, ratios, or percentages) to the right y-axis.
- Ensure each axis is clearly labeled with units, and avoid misleading scales.
- **Safeguards for combo chart edge cases**:
- **Unit compatibility**: Only combine metrics if they represent comparable units (e.g., counts vs. counts, dollars vs. dollars, percentages vs. percentages). Do not combine metrics with fundamentally different units (e.g., dollars vs clicks) on the same axis.
- **Scale alignment**: Before combining, compare the ranges of the metrics. If one metric is multiple orders of magnitude larger than the other (e.g., 5k-10k vs. 20M-40M), separate them into different charts or different axes.
- **Ratios and rates exception**: If one metric is a ratio or percentage (e.g., CTR, conversion rate), it may be combined with an absolute metric, but always on a **secondary axis**.
- Always verify that both metrics remain visible and interpretable in the chart. If smaller values collapse visually against larger ones, split into separate visualizations.
- Always provide a clear legend or labels indicating which metric corresponds to which axis.
- Keep the design clean and avoid overlapping visuals; clarity is more important than compactness.
- For ambiguous requests (e.g., "Show me our revenue"), default to line charts to show trends over time. This provides both the trend and the latest value, covering multiple possibilities
- Use number cards for displaying single values or key metrics (e.g., "Total Revenue: $1000")
- For requests identifying a single item (e.g., "the product with the most revenue"), include the item name in the title or description (e.g., "Revenue of Top Product: Product X - $500")

View File

@ -737,8 +737,13 @@ If all true → proceed to submit prep for Asset Creation with `submitThoughts`.
- Assign the primary metric (larger values or main focus) to the left y-axis.
- Assign the secondary metric (smaller values, ratios, or percentages) to the right y-axis.
- Ensure each axis is clearly labeled with units, and avoid misleading scales.
- **Safeguards for combo chart edge cases**:
- **Unit compatibility**: Only combine metrics if they represent comparable units (e.g., counts vs. counts, dollars vs. dollars, percentages vs. percentages). Do not combine metrics with fundamentally different units (e.g., dollars vs clicks) on the same axis.
- **Scale alignment**: Before combining, compare the ranges of the metrics. If one metric is multiple orders of magnitude larger than the other (e.g., 5k-10k vs. 20M-40M), separate them into different charts or different axes.
- **Ratios and rates exception**: If one metric is a ratio or percentage (e.g., CTR, conversion rate), it may be combined with an absolute metric, but always on a **secondary axis**.
- Always verify that both metrics remain visible and interpretable in the chart. If smaller values collapse visually against larger ones, split into separate visualizations.
- Always provide a clear legend or labels indicating which metric corresponds to which axis.
- Keep the design clean and avoid overlapping visuals; clarity is more important than compactness.
- Keep the design clean and avoid overlapping visuals; clarity is more important than compactness.
- Use tables only when:
- Specifically requested by the user.
- Displaying detailed lists with many items.

View File

@ -591,8 +591,13 @@ When in doubt, be more thorough rather than less. Reports are the default becaus
- Assign the primary metric (larger values or main focus) to the left y-axis.
- Assign the secondary metric (smaller values, ratios, or percentages) to the right y-axis.
- Ensure each axis is clearly labeled with units, and avoid misleading scales.
- **Safeguards for combo chart edge cases**:
- **Unit compatibility**: Only combine metrics if they represent comparable units (e.g., counts vs. counts, dollars vs. dollars, percentages vs. percentages). Do not combine metrics with fundamentally different units (e.g., dollars vs clicks) on the same axis.
- **Scale alignment**: Before combining, compare the ranges of the metrics. If one metric is multiple orders of magnitude larger than the other (e.g., 5k-10k vs. 20M-40M), separate them into different charts or different axes.
- **Ratios and rates exception**: If one metric is a ratio or percentage (e.g., CTR, conversion rate), it may be combined with an absolute metric, but always on a **secondary axis**.
- Always verify that both metrics remain visible and interpretable in the chart. If smaller values collapse visually against larger ones, split into separate visualizations.
- Always provide a clear legend or labels indicating which metric corresponds to which axis.
- Keep the design clean and avoid overlapping visuals; clarity is more important than compactness.
- Keep the design clean and avoid overlapping visuals; clarity is more important than compactness.
- Use tables only when:
- Specifically requested by the user.
- Displaying detailed lists with many items.

View File

@ -156,15 +156,51 @@ properties:
type: string
description: |
Human-readable time period covered by the SQL query, similar to a filter in a BI tool.
RULE: Must accurately reflect the date/time filter used in the `sql` field. Do not misrepresent the time range.
Examples:
## CRITICAL RULES
- Each metric/visualization should have its own unique timeframe string based on the date filters used in the metric's `sql` field
- The timeframe must be derived directly and only from that metrics `sql` field.
- Never generalize across metrics or apply a “one size fits all” timeframe for multiple charts. If charts are on the same report or dashboard but utilize different date filters, you must display the correct timeFrame for each metric/visualization.
- Must accurately reflect both the date filters and the grouping granularity used in the SQL.
## Allowed Formats
- Fixed Dates: "January 1, 2020 - December 31, 2020", "2024", "Q2 2024", "June 1, 2025"
- Relative Dates: "Today", "Yesterday", "Last 7 days", "Last 30 days", "Last Quarter", "Last 12 Months", "Year to Date", "All time"
- Comparisons: Use the format "Comparison: [Period 1] vs [Period 2]". Examples:
- "Comparison: Last 30 days vs Previous 30 days"
- "Comparison: June 1, 2025 - June 30, 2025 vs July 1, 2025 - July 31, 2025"
RULE: Use full month names for dates, e.g., "January", not "Jan".
RULE: Follow general quoting rules. CANNOT contain ':'.
- Relative Dates: "Today", "Yesterday", "Last 7 days", "Last 30 days", "Last Quarter",
"Last 12 Months", "Year to Date", "Quarter to Date", "Month to Date", "Week to Date", "All time"
- Comparisons: "Comparison - [Period 1] vs [Period 2]"
Examples:
- "Comparison - Last 30 days vs Previous 30 days"
- "Comparison - Last 12 Months vs Previous 12 Months"
- "Comparison - June 1, 2025 - June 30, 2025 vs July 1, 2025 - July 31, 2025"
- Fiscal Periods: "FY2024", "FQ3 2024"
- Forecast/Future: "Next 12 Months", "Next Quarter"
## Edge Case Rules
- **Incomplete start date only** (`WHERE date >= '2023-01-01'`) → "Since January 1, 2023"
- **Incomplete end date only** (`WHERE date <= '2024-06-30'`) → "Through June 30, 2024"
- **Current partial period** (`WHERE YEAR(order_date) = 2024` during 2024) → "Year to Date"
- **Custom rolling windows** (`WHERE date >= CURRENT_DATE - INTERVAL '90 days'`) → "Last 90 days"
- **Future projections** (`WHERE date BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '12 months'`) → "Next 12 Months"
- **Short fixed spans** (3 months of data: JanMar 2025) → "January 2025 - March 2025"
- **Fiscal calendar filters** (`WHERE fiscal_quarter = 'FQ3 2024'`) → "FQ3 2024"
- **Rolling to-date filters** (`DATE_TRUNC('quarter', CURRENT_DATE)`) → "Quarter to Date"
- **Relative offsets** (`WHERE date BETWEEN CURRENT_DATE - INTERVAL '60 days' AND CURRENT_DATE - INTERVAL '30 days'`) → "3060 Days Ago"
## Formatting Rules
- Always use full month names ("January", not "Jan").
- Do not include ":" inside the timeframe string.
- Timeframe string must match SQL exactly (filters and grouping).
- Never reuse the same timeframe across unrelated metrics unless the SQL is identical.
## Examples
- Query grouped by month, filtered last 12 months → "Last 12 Months"
- Query grouped by quarter, filtered current year → "Q1 2025 - Q3 2025"
- Query with WHERE order_date BETWEEN '2021-01-01' AND '2021-12-31' → "January 1, 2021 - December 31, 2021"
- Query with no date filter → "All time"
- Comparison of rolling periods → "Comparison - Last 30 days vs Previous 30 days"
- Open-ended filter (`>= 2023-01-01`) → "Since January 1, 2023"
- Forecasting 1 year ahead → "Next 12 Months"
- Fiscal quarter filter → "FQ3 2024"
# SQL QUERY
sql:

View File

@ -9,12 +9,8 @@ import {
users,
usersToOrganizations,
} from '../../schema';
import type { ChatListItem } from '../../schema-types';
import {
type PaginatedResponse,
PaginationInputSchema,
createPaginatedResponse,
} from '../shared-types';
import type { ChatListItem, PaginatedResponse } from '../../schema-types';
import { PaginationInputSchema, createPaginatedResponse } from '../../schema-types';
export const ListChatsRequestSchema = z
.object({

View File

@ -2,9 +2,12 @@ import { type InferSelectModel, and, count, desc, eq, gte, isNull, like, lte } f
import { z } from 'zod';
import { db } from '../../connection';
import { reportFiles, users } from '../../schema';
import {
type PaginatedResponse,
createPaginatedResponse,
withPagination,
} from '../../schema-types';
import { getUserOrganizationId } from '../organizations';
import { type PaginatedResponse, createPaginatedResponse } from '../shared-types';
import { withPagination } from '../shared-types/with-pagination';
export const GetReportsListInputSchema = z.object({
userId: z.string().uuid('User ID must be a valid UUID'),

View File

@ -2,9 +2,12 @@ import { type SQL, and, count, desc, eq, exists, isNull, ne, or, sql } from 'dri
import { z } from 'zod';
import { db } from '../../connection';
import { assetPermissions, reportFiles, teamsToUsers, users } from '../../schema';
import {
type PaginatedResponse,
createPaginatedResponse,
withPagination,
} from '../../schema-types';
import { getUserOrganizationId } from '../organizations';
import { type PaginatedResponse, createPaginatedResponse } from '../shared-types';
import { withPagination } from '../shared-types/with-pagination';
export const GetReportsWithPermissionsInputSchema = z.object({
userId: z.string().uuid('User ID must be a valid UUID'),

View File

@ -6,7 +6,7 @@ import {
type PaginatedResponse,
PaginationInputSchema,
createPaginatedResponse,
} from '../shared-types';
} from '../../schema-types';
import { createPermissionedAssetsSubquery } from './access-control-helpers';
import { AssetTypeSchema } from '../../schema-types/asset';
@ -100,6 +100,7 @@ export async function searchText(input: SearchTextInput): Promise<SearchTextResp
assetType: assetSearchV2.assetType,
title: assetSearchV2.title,
additionalText: assetSearchV2.additionalText,
updatedAt: assetSearchV2.updatedAt,
})
.from(assetSearchV2)
.innerJoin(

View File

@ -1,3 +0,0 @@
// Export pagination types and utilities
export * from './pagination.types';
export * from './with-pagination';

View File

@ -1,30 +0,0 @@
import { z } from 'zod';
// Pagination input schema for validation
export const PaginationInputSchema = z.object({
page: z.number().min(1).optional().default(1),
page_size: z.number().min(1).max(1000).optional().default(250),
});
export type PaginationInput = z.infer<typeof PaginationInputSchema>;
// Pagination metadata that's returned with results
export interface PaginationMetadata {
page: number;
page_size: number;
total: number;
total_pages: number;
}
// Generic paginated response type
export interface PaginatedResponse<T> {
data: T[];
pagination: PaginationMetadata;
}
// Type helper for creating paginated API responses
export type WithPagination<T> = {
[K in keyof T]: T[K];
} & {
pagination: PaginationMetadata;
};

View File

@ -8,9 +8,12 @@ import {
usersToOrganizations,
} from '../../schema';
import { UserOrganizationRoleSchema, UserOrganizationStatusSchema } from '../../schema-types';
import {
type PaginatedResponse,
createPaginatedResponse,
withPagination,
} from '../../schema-types';
import { getUserOrganizationId } from '../organizations/organizations';
import { type PaginatedResponse, createPaginatedResponse } from '../shared-types';
import { withPagination } from '../shared-types/with-pagination';
// Type-safe schema types
type User = InferSelectModel<typeof users>;

View File

@ -37,3 +37,5 @@ export * from './github';
export * from './search';
export * from './chat';
export * from './pagination';

View File

@ -1,6 +1,36 @@
import type { SQL } from 'drizzle-orm';
import type { PgColumn, PgSelect } from 'drizzle-orm/pg-core';
import type { PaginatedResponse, PaginationMetadata } from './pagination.types';
import { z } from 'zod';
// Pagination input schema for validation
export const PaginationInputSchema = z.object({
page: z.coerce.number().min(1).optional().default(1),
page_size: z.coerce.number().min(1).max(5000).optional().default(250),
});
export type PaginationInput = z.infer<typeof PaginationInputSchema>;
export const PaginationSchema = z.object({
page: z.number(),
page_size: z.number(),
total: z.number(),
total_pages: z.number(),
});
export type PaginationMetadata = z.infer<typeof PaginationSchema>;
// Generic paginated response type
export interface PaginatedResponse<T> {
data: T[];
pagination: PaginationMetadata;
}
// Type helper for creating paginated API responses
export type WithPagination<T> = {
[K in keyof T]: T[K];
} & {
pagination: PaginationMetadata;
};
/**
* Adds pagination to a Drizzle query using the dynamic query builder pattern

View File

@ -1,8 +1,10 @@
import z from 'zod';
import { AssetTypeSchema } from './asset';
export const TextSearchResultSchema = z.object({
assetId: z.string().uuid(),
assetType: z.string(),
assetType: AssetTypeSchema,
title: z.string(),
additionalText: z.string().nullable(),
updatedAt: z.string().datetime(),
});

View File

@ -99,6 +99,10 @@
"./healthcheck": {
"types": "./dist/healthcheck/index.d.ts",
"default": "./dist/healthcheck/index.js"
},
"./search": {
"types": "./dist/search/index.d.ts",
"default": "./dist/search/index.js"
}
},
"module": "src/index.ts",

View File

@ -28,7 +28,8 @@ export const SearchTextRequestSchema = z
}
return Boolean(val);
}, z.boolean())
.default(false),
.default(false)
.optional(),
endDate: z.string().datetime().optional(),
startDate: z.string().datetime().optional(),
})

View File

@ -1,13 +1,13 @@
import {
PaginationInputSchema,
type PaginationMetadata,
PaginationSchema,
} from '@buster/database/schema-types';
import { z } from 'zod';
export const PaginationSchema = z.object({
page: z.number(),
page_size: z.number(),
total: z.number(),
total_pages: z.number(),
});
export { PaginationSchema, type PaginationMetadata } from '@buster/database/schema-types';
export type Pagination = z.infer<typeof PaginationSchema>;
export type Pagination = PaginationMetadata;
export const PaginatedResponseSchema = <T>(schema: z.ZodType<T>) =>
z.object({
@ -17,7 +17,4 @@ export const PaginatedResponseSchema = <T>(schema: z.ZodType<T>) =>
export type PaginatedResponse<T> = z.infer<ReturnType<typeof PaginatedResponseSchema<T>>>;
export const PaginatedRequestSchema = z.object({
page: z.coerce.number().min(1).default(1),
page_size: z.coerce.number().min(1).max(5000).default(250),
});
export const PaginatedRequestSchema = PaginationInputSchema;

View File

@ -703,6 +703,9 @@ importers:
'@tiptap/suggestion':
specifier: ^3.5.0
version: 3.5.0(@tiptap/core@3.5.0(@tiptap/pm@3.5.0))(@tiptap/pm@3.5.0)
'@types/qs':
specifier: ^6.14.0
version: 6.14.0
'@udecode/cn':
specifier: ^49.0.15
version: 49.0.15(@types/react@19.1.13)(class-variance-authority@0.7.1)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(tailwind-merge@3.3.1)
@ -793,6 +796,9 @@ importers:
posthog-js:
specifier: ^1.268.1
version: 1.268.1
qs:
specifier: ^6.14.0
version: 6.14.0
react:
specifier: 'catalog:'
version: 19.1.1
@ -6356,6 +6362,9 @@ packages:
'@types/prismjs@1.26.5':
resolution: {integrity: sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ==}
'@types/qs@6.14.0':
resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==}
'@types/react-dom@19.1.9':
resolution: {integrity: sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==}
peerDependencies:
@ -19323,6 +19332,8 @@ snapshots:
'@types/prismjs@1.26.5': {}
'@types/qs@6.14.0': {}
'@types/react-dom@19.1.9(@types/react@19.1.13)':
dependencies:
'@types/react': 19.1.13
@ -19591,7 +19602,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.5.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@22.18.1)(typescript@5.9.2))(sass@1.93.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.5.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.1)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
'@vitest/utils@3.2.4':
dependencies: