feat: 🎸 max group qol changes + fixes
This commit is contained in:
@@ -211,7 +211,12 @@ const formatSimpleResults = ({
|
||||
return { meta, rows: data.length, data };
|
||||
};
|
||||
|
||||
/** Formats groupable pipe results (with grouping) into unpivoted format */
|
||||
/**
|
||||
* Formats groupable pipe results using per-bin ranking.
|
||||
* The Tinybird pipe already ranks per-bin and buckets overflow into AUTUMN_RESERVED.
|
||||
* This function trusts that ranking — each bin keeps its own top N groups,
|
||||
* so different bins can show different entities.
|
||||
*/
|
||||
const formatGroupableResults = ({
|
||||
rows,
|
||||
eventNames,
|
||||
@@ -220,7 +225,6 @@ const formatGroupableResults = ({
|
||||
startDate,
|
||||
endDate,
|
||||
binSize,
|
||||
maxGroups = 9,
|
||||
}: {
|
||||
rows: AggregateGroupablePipeRow[];
|
||||
eventNames: string[];
|
||||
@@ -232,46 +236,17 @@ const formatGroupableResults = ({
|
||||
maxGroups?: number;
|
||||
}): ClickHouseResult => {
|
||||
const allPeriods = generateAllPeriods({ startDate, endDate, binSize });
|
||||
// groupBy already comes with "properties." prefix from frontend
|
||||
const groupByColumn = groupBy;
|
||||
|
||||
// Compute global totals per group value across all bins,
|
||||
// then keep only the top maxGroups. This prevents the union
|
||||
// of per-bin top-N from exceeding the intended group limit.
|
||||
const globalTotals = new Map<string, number>();
|
||||
// Collect all unique group values across all bins (for backfilling zeros).
|
||||
// Each bin may have a different set of top-N groups, so the union can exceed N.
|
||||
const allGroupValues = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (!row.group_value || row.group_value === "AUTUMN_RESERVED") continue;
|
||||
globalTotals.set(
|
||||
row.group_value,
|
||||
(globalTotals.get(row.group_value) ?? 0) + row.total_value,
|
||||
);
|
||||
if (row.group_value) {
|
||||
allGroupValues.add(row.group_value);
|
||||
}
|
||||
}
|
||||
|
||||
const sortedGroups = Array.from(globalTotals.entries()).sort(
|
||||
(a, b) => b[1] - a[1],
|
||||
);
|
||||
|
||||
const topGroupValues = new Set(
|
||||
sortedGroups.slice(0, maxGroups).map(([gv]) => gv),
|
||||
);
|
||||
|
||||
// If there are overflow groups, fold them into AUTUMN_RESERVED
|
||||
const hasOverflow =
|
||||
sortedGroups.length > maxGroups ||
|
||||
rows.some((r) => r.group_value === "AUTUMN_RESERVED");
|
||||
|
||||
if (hasOverflow) {
|
||||
topGroupValues.add("AUTUMN_RESERVED");
|
||||
}
|
||||
|
||||
// Re-bucket: rows whose group_value isn't in topGroupValues become AUTUMN_RESERVED
|
||||
const rebucketed: AggregateGroupablePipeRow[] = rows.map((row) => {
|
||||
if (!row.group_value || topGroupValues.has(row.group_value)) return row;
|
||||
return { ...row, group_value: "AUTUMN_RESERVED" };
|
||||
});
|
||||
|
||||
const allGroupValues = topGroupValues;
|
||||
|
||||
// Build a map of (period, groupValue) -> { event_name: value }
|
||||
const dataMap = new Map<string, Map<string, Record<string, number>>>();
|
||||
|
||||
@@ -288,8 +263,8 @@ const formatGroupableResults = ({
|
||||
dataMap.set(period, groupMap);
|
||||
}
|
||||
|
||||
// Fill in actual data (use rebucketed rows so overflow groups are merged)
|
||||
for (const row of rebucketed) {
|
||||
// Fill in actual data directly from the pipe output
|
||||
for (const row of rows) {
|
||||
if (!row.group_value) continue;
|
||||
|
||||
const groupMap = dataMap.get(row.period);
|
||||
@@ -303,7 +278,6 @@ const formatGroupableResults = ({
|
||||
eventName: row.event_name,
|
||||
noCount,
|
||||
});
|
||||
// Use += to aggregate multiple rebucketed rows into AUTUMN_RESERVED
|
||||
record[columnName] = new Decimal(record[columnName] ?? 0)
|
||||
.plus(new Decimal(row.total_value))
|
||||
.toDecimalPlaces(10)
|
||||
@@ -323,13 +297,14 @@ const formatGroupableResults = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by period then group value (but put "Other" last within each period)
|
||||
// Sort by period then group value (put AUTUMN_RESERVED last within each period)
|
||||
data.sort((a, b) => {
|
||||
const periodCompare = String(a.period).localeCompare(String(b.period));
|
||||
if (periodCompare !== 0) return periodCompare;
|
||||
// Put "Other" last
|
||||
const aIsOther = a[groupByColumn] === "Other";
|
||||
const bIsOther = b[groupByColumn] === "Other";
|
||||
const aIsOther =
|
||||
a[groupByColumn] === "AUTUMN_RESERVED" || a[groupByColumn] === "Other";
|
||||
const bIsOther =
|
||||
b[groupByColumn] === "AUTUMN_RESERVED" || b[groupByColumn] === "Other";
|
||||
if (aIsOther && !bIsOther) return 1;
|
||||
if (!aIsOther && bIsOther) return -1;
|
||||
return String(a[groupByColumn]).localeCompare(String(b[groupByColumn]));
|
||||
|
||||
65
server/src/internal/analytics/actions/getEntityNames.ts
Normal file
65
server/src/internal/analytics/actions/getEntityNames.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getClickhouseClient } from "@/external/tinybird/initClickhouse.js";
|
||||
|
||||
type EntityNameRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** Escapes a string for safe use in a ClickHouse string literal (single-quoted). */
|
||||
const escapeChString = ({ value }: { value: string }): string =>
|
||||
value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
|
||||
/** Looks up entity names from the entities datasource by their IDs. Returns a map of id -> name (or id if name is null/empty). */
|
||||
export const getEntityNames = async ({
|
||||
entityIds,
|
||||
orgId,
|
||||
env,
|
||||
}: {
|
||||
entityIds: string[];
|
||||
orgId: string;
|
||||
env: string;
|
||||
}): Promise<Record<string, string>> => {
|
||||
if (entityIds.length === 0) return {};
|
||||
|
||||
const ch = getClickhouseClient();
|
||||
|
||||
// Build the IN list as escaped literals to avoid URI-too-large
|
||||
// when the array is serialized as a query parameter.
|
||||
const inList = entityIds
|
||||
.map((id) => `'${escapeChString({ value: id })}'`)
|
||||
.join(",");
|
||||
|
||||
const query = `
|
||||
SELECT id, name
|
||||
FROM entities FINAL
|
||||
WHERE org_id = {org_id:String}
|
||||
AND env = {env:String}
|
||||
AND id IN (${inList})
|
||||
AND deleted = 0
|
||||
`;
|
||||
|
||||
const result = await ch.query({
|
||||
query,
|
||||
query_params: {
|
||||
org_id: orgId,
|
||||
env,
|
||||
},
|
||||
format: "JSON",
|
||||
});
|
||||
|
||||
const resultJson = (await result.json()) as { data: EntityNameRow[] };
|
||||
|
||||
const nameMap: Record<string, string> = {};
|
||||
for (const row of resultJson.data) {
|
||||
nameMap[row.id] = row.name || row.id;
|
||||
}
|
||||
|
||||
// For any IDs not found in the datasource, fall back to the ID itself
|
||||
for (const id of entityIds) {
|
||||
if (!nameMap[id]) {
|
||||
nameMap[id] = id;
|
||||
}
|
||||
}
|
||||
|
||||
return nameMap;
|
||||
};
|
||||
@@ -9,6 +9,7 @@ import { StatusCodes } from "http-status-codes";
|
||||
import { z } from "zod/v4";
|
||||
import { assertTinybirdAvailable } from "@/external/tinybird/tinybirdUtils.js";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler.js";
|
||||
import { getEntityNames } from "@/internal/analytics/actions/getEntityNames.js";
|
||||
import { CusService } from "@/internal/customers/CusService.js";
|
||||
import { eventActions } from "../actions/eventActions.js";
|
||||
|
||||
@@ -99,6 +100,28 @@ export const handleInternalAggregateEvents = createRoute({
|
||||
},
|
||||
});
|
||||
|
||||
// When grouping by entity_id, resolve entity names from ClickHouse
|
||||
let entityNames: Record<string, string> | undefined;
|
||||
if (group_by === "entity_id" && events?.data) {
|
||||
const entityIds = [
|
||||
...new Set(
|
||||
events.data
|
||||
.map((row: Record<string, unknown>) => row.entity_id as string)
|
||||
.filter(
|
||||
(id: string) => id && id !== "AUTUMN_RESERVED" && id !== "",
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
if (entityIds.length > 0) {
|
||||
entityNames = await getEntityNames({
|
||||
entityIds,
|
||||
orgId: org.id,
|
||||
env,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
customer,
|
||||
events,
|
||||
@@ -106,6 +129,7 @@ export const handleInternalAggregateEvents = createRoute({
|
||||
eventNames: event_names,
|
||||
bcExclusionFlag,
|
||||
truncated,
|
||||
entityNames,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ SQL >
|
||||
NODE endpoint
|
||||
TYPE endpoint
|
||||
SQL >
|
||||
%
|
||||
SELECT
|
||||
period,
|
||||
event_name,
|
||||
|
||||
@@ -52,6 +52,7 @@ export const AnalyticsView = () => {
|
||||
bcExclusionFlag,
|
||||
groupBy,
|
||||
truncated,
|
||||
entityNames,
|
||||
} = useAnalyticsData({ hasCleared });
|
||||
|
||||
// Show toast when data is truncated due to too many unique group values
|
||||
@@ -140,10 +141,11 @@ export const AnalyticsView = () => {
|
||||
features,
|
||||
groupBy,
|
||||
originalColors: colors,
|
||||
entityNames,
|
||||
});
|
||||
|
||||
return { chartData: transformed, chartConfig: config };
|
||||
}, [events, features, groupBy, groupFilter]);
|
||||
}, [events, features, groupBy, groupFilter, entityNames]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error?.response?.data?.code === ErrCode.ClickHouseDisabled) {
|
||||
@@ -226,6 +228,7 @@ export const AnalyticsView = () => {
|
||||
groupFilter,
|
||||
setGroupFilter,
|
||||
availableGroupValues,
|
||||
entityNames,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4 h-full relative w-full text-sm pb-8 max-w-5xl mx-auto px-4 sm:px-10 pt-4 sm:pt-8">
|
||||
|
||||
@@ -183,9 +183,7 @@ export const QueryTopbar = () => {
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<SelectFeatureDropdown />
|
||||
{propertyKeys && propertyKeys.length > 0 && (
|
||||
<SelectGroupByDropdown propertyKeys={propertyKeys} />
|
||||
)}
|
||||
<SelectGroupByDropdown propertyKeys={propertyKeys ?? []} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { CaretDownIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import {
|
||||
CaretDownIcon,
|
||||
MagnifyingGlassIcon,
|
||||
PencilSimpleIcon,
|
||||
} from "@phosphor-icons/react";
|
||||
import { Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import {
|
||||
@@ -26,12 +30,13 @@ export const SelectGroupByDropdown = ({
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const { groupFilter, setGroupFilter, availableGroupValues } =
|
||||
const { groupFilter, setGroupFilter, availableGroupValues, entityNames } =
|
||||
useAnalyticsContext();
|
||||
|
||||
const currentGroupBy = searchParams.get("group_by") || "";
|
||||
const customerId = searchParams.get("customer_id");
|
||||
const showCustomerIdOption = !customerId;
|
||||
const maxGroups = Number(searchParams.get("max_groups")) || 10;
|
||||
|
||||
const updateQueryParams = ({ groupBy }: { groupBy: string | null }) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
@@ -40,11 +45,19 @@ export const SelectGroupByDropdown = ({
|
||||
params.set("group_by", groupBy);
|
||||
} else {
|
||||
params.delete("group_by");
|
||||
params.delete("max_groups");
|
||||
}
|
||||
|
||||
navigate(`${location.pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const updateMaxGroups = ({ value }: { value: number }) => {
|
||||
const clamped = Math.min(250, Math.max(1, value));
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.set("max_groups", String(clamped));
|
||||
navigate(`${location.pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const filteredOptions = propertyKeys.filter((key) =>
|
||||
key.toLowerCase().includes(searchValue.toLowerCase()),
|
||||
);
|
||||
@@ -54,7 +67,34 @@ export const SelectGroupByDropdown = ({
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const displayValue = currentGroupBy || "No grouping";
|
||||
const [editingMaxGroups, setEditingMaxGroups] = useState(false);
|
||||
const [maxGroupsDraft, setMaxGroupsDraft] = useState(String(maxGroups));
|
||||
const maxGroupsInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Sync draft when maxGroups changes externally
|
||||
useEffect(() => {
|
||||
if (!editingMaxGroups) {
|
||||
setMaxGroupsDraft(String(maxGroups));
|
||||
}
|
||||
}, [maxGroups, editingMaxGroups]);
|
||||
|
||||
// Focus input when entering edit mode
|
||||
useEffect(() => {
|
||||
if (editingMaxGroups) {
|
||||
maxGroupsInputRef.current?.focus();
|
||||
maxGroupsInputRef.current?.select();
|
||||
}
|
||||
}, [editingMaxGroups]);
|
||||
|
||||
const commitMaxGroups = () => {
|
||||
const val = Number.parseInt(maxGroupsDraft, 10);
|
||||
if (!Number.isNaN(val) && val !== maxGroups) {
|
||||
updateMaxGroups({ value: val });
|
||||
} else {
|
||||
setMaxGroupsDraft(String(maxGroups));
|
||||
}
|
||||
setEditingMaxGroups(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
@@ -139,6 +179,51 @@ export const SelectGroupByDropdown = ({
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
{/* Max groups - only shown when a groupBy is selected */}
|
||||
{currentGroupBy && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className="text-xs text-t3">Max groups</span>
|
||||
{editingMaxGroups ? (
|
||||
<input
|
||||
ref={maxGroupsInputRef}
|
||||
type="number"
|
||||
value={maxGroupsDraft}
|
||||
min={1}
|
||||
max={250}
|
||||
onChange={(e) => setMaxGroupsDraft(e.target.value)}
|
||||
onBlur={commitMaxGroups}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === "Enter") {
|
||||
commitMaxGroups();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setMaxGroupsDraft(String(maxGroups));
|
||||
setEditingMaxGroups(false);
|
||||
}
|
||||
}}
|
||||
className="w-12 text-center text-xs bg-transparent border border-border rounded px-1 py-0.5 outline-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setEditingMaxGroups(true);
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-t2 hover:text-t1"
|
||||
>
|
||||
{maxGroups}
|
||||
<PencilSimpleIcon size={10} className="text-t4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Filter section - only shown when a groupBy is selected */}
|
||||
{currentGroupBy && availableGroupValues.length > 0 && (
|
||||
<>
|
||||
@@ -153,20 +238,26 @@ export const SelectGroupByDropdown = ({
|
||||
<span className="text-xs">All values</span>
|
||||
{!groupFilter && <Check className="ml-2 h-3 w-3 text-t3" />}
|
||||
</DropdownMenuItem>
|
||||
{availableGroupValues.map((value) => (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => setGroupFilter(value)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="text-xs font-mono truncate max-w-[150px]">
|
||||
{value}
|
||||
</span>
|
||||
{groupFilter === value && (
|
||||
<Check className="ml-2 h-3 w-3 text-t3 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
{availableGroupValues.map((value: string) => {
|
||||
const displayValue =
|
||||
value === "AUTUMN_RESERVED"
|
||||
? "Other values"
|
||||
: (entityNames?.[value] ?? value);
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={value}
|
||||
onClick={() => setGroupFilter(value)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="text-xs font-mono truncate max-w-[150px]">
|
||||
{displayValue}
|
||||
</span>
|
||||
{groupFilter === value && (
|
||||
<Check className="ml-2 h-3 w-3 text-t3 shrink-0" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,7 @@ export const useAnalyticsData = ({
|
||||
const interval = searchParams.get("interval");
|
||||
const groupBy = searchParams.get("group_by");
|
||||
const binSize = searchParams.get("bin_size");
|
||||
const maxGroups = Number(searchParams.get("max_groups")) || 10;
|
||||
|
||||
const { eventNames: cachedEventNames } = useEventNames();
|
||||
|
||||
@@ -59,6 +60,7 @@ export const useAnalyticsData = ({
|
||||
group_by: formattedGroupBy,
|
||||
bin_size: binSize || undefined,
|
||||
timezone,
|
||||
max_groups: formattedGroupBy ? maxGroups : undefined,
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -75,6 +77,7 @@ export const useAnalyticsData = ({
|
||||
...selectedEventNames.sort(),
|
||||
groupBy,
|
||||
timezone,
|
||||
String(maxGroups),
|
||||
]),
|
||||
queryFn: async () => {
|
||||
const { data } = await axiosInstance.post("/query/events", postBody);
|
||||
@@ -88,12 +91,14 @@ export const useAnalyticsData = ({
|
||||
featuresLoading,
|
||||
queryLoading,
|
||||
events: data?.events,
|
||||
error: error && (error as any)?.code === ErrCode.ClickHouseDisabled
|
||||
? null
|
||||
: error,
|
||||
error:
|
||||
error && (error as any)?.code === ErrCode.ClickHouseDisabled
|
||||
? null
|
||||
: error,
|
||||
bcExclusionFlag: data?.bcExclusionFlag ?? false,
|
||||
groupBy,
|
||||
truncated: data?.truncated ?? false,
|
||||
entityNames: (data?.entityNames as Record<string, string>) ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -138,8 +143,9 @@ export const useRawAnalyticsData = () => {
|
||||
featuresLoading,
|
||||
queryLoading,
|
||||
rawEvents: data?.rawEvents,
|
||||
error: error && (error as any)?.code === ErrCode.ClickHouseDisabled
|
||||
? null
|
||||
: error,
|
||||
error:
|
||||
error && (error as any)?.code === ErrCode.ClickHouseDisabled
|
||||
? null
|
||||
: error,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -176,11 +176,13 @@ export function generateChartConfig({
|
||||
features,
|
||||
groupBy,
|
||||
originalColors,
|
||||
entityNames,
|
||||
}: {
|
||||
events: EventsData;
|
||||
features: Feature[];
|
||||
groupBy: string | null;
|
||||
originalColors: string[];
|
||||
entityNames?: Record<string, string>;
|
||||
}): ChartSeriesConfig[] {
|
||||
const colorsToUse = groupBy ? CHART_COLORS : originalColors;
|
||||
|
||||
@@ -213,8 +215,14 @@ export function generateChartConfig({
|
||||
const groupValue = parts[parts.length - 1];
|
||||
|
||||
const featureName = getFeatureName({ key: featureKey, features });
|
||||
const displayGroupValue =
|
||||
groupValue === "AUTUMN_RESERVED" ? "Other values" : groupValue;
|
||||
let displayGroupValue: string;
|
||||
if (groupValue === "AUTUMN_RESERVED") {
|
||||
displayGroupValue = "Other values";
|
||||
} else if (entityNames?.[groupValue]) {
|
||||
displayGroupValue = entityNames[groupValue];
|
||||
} else {
|
||||
displayGroupValue = groupValue;
|
||||
}
|
||||
|
||||
config.push({
|
||||
xKey: "period",
|
||||
|
||||
Reference in New Issue
Block a user