feat: 🎸 max group qol changes + fixes

This commit is contained in:
amianthus
2026-03-27 13:03:20 +00:00
parent 96786b8c91
commit fe553f769f
9 changed files with 245 additions and 74 deletions

View File

@@ -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]));

View 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;
};

View File

@@ -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,
});
},
});

View File

@@ -69,6 +69,7 @@ SQL >
NODE endpoint
TYPE endpoint
SQL >
%
SELECT
period,
event_name,

View File

@@ -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">

View File

@@ -183,9 +183,7 @@ export const QueryTopbar = () => {
</DropdownMenuContent>
</DropdownMenu>
<SelectFeatureDropdown />
{propertyKeys && propertyKeys.length > 0 && (
<SelectGroupByDropdown propertyKeys={propertyKeys} />
)}
<SelectGroupByDropdown propertyKeys={propertyKeys ?? []} />
</div>
);
};

View File

@@ -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>

View File

@@ -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,
};
};

View File

@@ -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",