@@ -1,7 +1,3 @@
|
||||
/** biome-ignore-all lint/complexity/noStaticOnlyClass: wrap it up buddy */
|
||||
|
||||
/** biome-ignore-all lint/complexity/noStaticOnlyClass: wrap it up buddy */
|
||||
|
||||
import {
|
||||
ErrCode,
|
||||
type FullCustomer,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
FeatureType,
|
||||
type FullCusProduct,
|
||||
type FullCustomer,
|
||||
type RangeEnum,
|
||||
} from "@autumn/shared";
|
||||
import { Router } from "express";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
@@ -12,6 +13,7 @@ import RecaseError from "@/utils/errorUtils.js";
|
||||
import type { ExtendedRequest } from "@/utils/models/Request.js";
|
||||
import { routeHandler } from "@/utils/routerUtils.js";
|
||||
import { CusService } from "../customers/CusService.js";
|
||||
import { EventsAggregationService } from "../events/EventsAggregationService.js";
|
||||
import { AnalyticsService } from "./AnalyticsService.js";
|
||||
|
||||
export const analyticsRouter = Router();
|
||||
@@ -111,7 +113,7 @@ analyticsRouter.post("/events", async (req: any, res: any) =>
|
||||
handler: async () => {
|
||||
AnalyticsService.handleEarlyExit();
|
||||
const { db, org, env, features } = req;
|
||||
let { interval, event_names, customer_id } = req.body;
|
||||
let { interval, event_names, customer_id, group_by } = req.body;
|
||||
|
||||
let topEvents: { featureIds: string[]; eventNames: string[] } | undefined;
|
||||
|
||||
@@ -159,15 +161,29 @@ analyticsRouter.post("/events", async (req: any, res: any) =>
|
||||
event_names = event_names.filter((name: string) => name !== "");
|
||||
}
|
||||
|
||||
const events = await AnalyticsService.getTimeseriesEvents({
|
||||
req,
|
||||
// const events = await AnalyticsService.getTimeseriesEvents({
|
||||
// req,
|
||||
// params: {
|
||||
// customer_id,
|
||||
// interval,
|
||||
// event_names,
|
||||
// },
|
||||
// customer,
|
||||
// aggregateAll,
|
||||
// });
|
||||
|
||||
const binSize = interval === "24h" ? "hour" : "day";
|
||||
|
||||
const events = await EventsAggregationService.getTimeseriesEvents({
|
||||
ctx: req,
|
||||
params: {
|
||||
customer_id,
|
||||
interval,
|
||||
interval: interval as RangeEnum,
|
||||
event_names,
|
||||
bin_size: binSize,
|
||||
aggregateAll,
|
||||
group_by: group_by,
|
||||
},
|
||||
customer,
|
||||
aggregateAll,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
|
||||
@@ -236,6 +236,47 @@ export class EventsAggregationService {
|
||||
const groupBy = getGroupByClause();
|
||||
const groupByFieldName = groupBy.fieldName;
|
||||
|
||||
// Validate distinct group count if group_by is provided
|
||||
if (params.group_by && groupBy.groupBy) {
|
||||
const propertyPath = params.group_by.replace("properties.", "");
|
||||
const pathSegments = propertyPath.split(".");
|
||||
const escapedPathArgs = pathSegments
|
||||
.map((seg) => `'${seg.replace(/'/g, "''")}'`)
|
||||
.join(", ");
|
||||
const groupField = `JSONExtractString(e.properties, ${escapedPathArgs})`;
|
||||
|
||||
const distinctCountQuery = `
|
||||
SELECT COUNT(DISTINCT ${groupField}) as distinct_count
|
||||
FROM org_events_view(org_id={org_id:String}, org_slug='', env={env:String}) e
|
||||
${params.aggregateAll ? "" : "WHERE e.customer_id = {customer_id:String}"}
|
||||
`;
|
||||
|
||||
const distinctResult = await (clickhouseClient as ClickHouseClient).query(
|
||||
{
|
||||
query: distinctCountQuery,
|
||||
query_params: {
|
||||
org_id: org?.id,
|
||||
env: env,
|
||||
customer_id: params.customer_id,
|
||||
},
|
||||
format: "JSON",
|
||||
},
|
||||
);
|
||||
|
||||
const distinctJson = (await distinctResult.json()) as ClickHouseResult<{
|
||||
distinct_count: string;
|
||||
}>;
|
||||
const distinctCount = Number(distinctJson.data[0]?.distinct_count ?? 0);
|
||||
|
||||
if (distinctCount > 30) {
|
||||
throw new RecaseError({
|
||||
message: `Too many distinct group values (${distinctCount}). Maximum allowed is 30. Please choose a property with fewer unique values.`,
|
||||
code: ErrCode.InvalidInputs,
|
||||
statusCode: StatusCodes.BAD_REQUEST,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const query = `
|
||||
with customer_events as (
|
||||
select *
|
||||
|
||||
@@ -75,6 +75,7 @@ export function EventsBarChart({
|
||||
chartConfig: any;
|
||||
}) {
|
||||
const { selectedInterval } = useAnalyticsContext();
|
||||
|
||||
const [options, setOptions] = useState<AgChartOptions>({
|
||||
data: data.data,
|
||||
series: chartConfig,
|
||||
@@ -138,11 +139,9 @@ export function EventsBarChart({
|
||||
},
|
||||
legend: {
|
||||
enabled: false,
|
||||
item: {
|
||||
label: {
|
||||
color: "#52525b",
|
||||
},
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ErrCode, type Feature, FeatureType } from "@autumn/shared";
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import { ChartBarIcon, DatabaseIcon } from "@phosphor-icons/react";
|
||||
import type { AgGridReact } from "ag-grid-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { EmptyState } from "@/components/v2/empty-states/EmptyState";
|
||||
@@ -14,6 +14,11 @@ import {
|
||||
useAnalyticsData,
|
||||
useRawAnalyticsData,
|
||||
} from "./hooks/useAnalyticsData";
|
||||
import { extractPropertyKeys } from "./utils/extractPropertyKeys";
|
||||
import {
|
||||
generateChartConfig,
|
||||
transformGroupedData,
|
||||
} from "./utils/transformGroupedChartData";
|
||||
|
||||
export const AnalyticsView = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -25,6 +30,7 @@ export const AnalyticsView = () => {
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(0);
|
||||
const [totalRows, setTotalRows] = useState(0);
|
||||
const [groupFilter, setGroupFilter] = useState<string | null>(null);
|
||||
const gridRef = useRef<AgGridReact>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
@@ -39,43 +45,78 @@ export const AnalyticsView = () => {
|
||||
bcExclusionFlag,
|
||||
topEventsLoading,
|
||||
topEvents,
|
||||
groupBy,
|
||||
} = useAnalyticsData({ hasCleared });
|
||||
|
||||
// Clear the filter when groupBy changes
|
||||
useEffect(() => {
|
||||
setGroupFilter(null);
|
||||
}, [groupBy]);
|
||||
|
||||
// Extract unique group values from events data for filtering
|
||||
const availableGroupValues = useMemo(() => {
|
||||
if (!groupBy || !events?.data) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const groupByColumn = `properties.${groupBy}`;
|
||||
const uniqueValues = new Set<string>();
|
||||
|
||||
for (const row of events.data) {
|
||||
const value = row[groupByColumn];
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
uniqueValues.add(String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(uniqueValues).sort();
|
||||
}, [groupBy, events?.data]);
|
||||
|
||||
const { rawEvents, queryLoading: rawQueryLoading } = useRawAnalyticsData();
|
||||
|
||||
const chartConfig = events?.meta
|
||||
.filter((x: { name: string }) => x.name !== "period")
|
||||
.map((x: { name: string }, index: number) => {
|
||||
if (x.name !== "period") {
|
||||
const colorIndex = index % colors.length;
|
||||
// Extract property keys from raw events for the group by dropdown
|
||||
const propertyKeys = useMemo(() => {
|
||||
return extractPropertyKeys({ rawEvents: rawEvents?.data });
|
||||
}, [rawEvents?.data]);
|
||||
|
||||
return {
|
||||
xKey: "period",
|
||||
yKey: x.name,
|
||||
type: "bar",
|
||||
stacked: true,
|
||||
yName:
|
||||
features.find((feature: Feature) => {
|
||||
const eventName = x.name.replace("_count", "");
|
||||
// Transform and configure chart data
|
||||
const { chartData, chartConfig } = useMemo(() => {
|
||||
if (!events) {
|
||||
return { chartData: null, chartConfig: null };
|
||||
}
|
||||
|
||||
// console.log("Feature: ", feature, eventName);
|
||||
// Apply frontend filter if a group filter is selected
|
||||
let filteredEvents = events;
|
||||
if (groupBy && groupFilter) {
|
||||
const groupByColumn = `properties.${groupBy}`;
|
||||
const filteredData = events.data.filter(
|
||||
(row: Record<string, string | number>) =>
|
||||
String(row[groupByColumn]) === groupFilter,
|
||||
);
|
||||
filteredEvents = {
|
||||
...events,
|
||||
data: filteredData,
|
||||
rows: filteredData.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (feature.type === FeatureType.Boolean) return false;
|
||||
|
||||
if (feature.id === eventName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (feature.event_names && feature.event_names.length > 0) {
|
||||
return feature.event_names.includes(eventName);
|
||||
}
|
||||
return false;
|
||||
})?.name || x.name.replace("_count", ""),
|
||||
fill: colors[colorIndex],
|
||||
};
|
||||
} else return null;
|
||||
// Transform data for grouped display (pivots rows into columns per group)
|
||||
const transformed = transformGroupedData({
|
||||
events: filteredEvents,
|
||||
groupBy,
|
||||
});
|
||||
|
||||
// Generate chart config with different colors per group
|
||||
const config = generateChartConfig({
|
||||
events: transformed,
|
||||
features,
|
||||
groupBy,
|
||||
originalColors: colors,
|
||||
});
|
||||
|
||||
return { chartData: transformed, chartConfig: config };
|
||||
}, [events, features, groupBy, groupFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error?.response?.data?.code === ErrCode.ClickHouseDisabled) {
|
||||
setClickHouseDisabled(true);
|
||||
@@ -131,6 +172,10 @@ export const AnalyticsView = () => {
|
||||
totalRows,
|
||||
setTotalRows,
|
||||
topEvents,
|
||||
propertyKeys,
|
||||
groupFilter,
|
||||
setGroupFilter,
|
||||
availableGroupValues,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4 h-full relative w-full text-sm pb-8 max-w-5xl mx-auto px-10 pt-8">
|
||||
@@ -151,13 +196,18 @@ export const AnalyticsView = () => {
|
||||
)}
|
||||
|
||||
<div className="h-full overflow-hidden">
|
||||
{events && events.data.length > 0 && (
|
||||
{chartData && chartData.data.length > 0 && (
|
||||
<div className="h-full overflow-hidden bg-interactive-secondary border max-h-[350px]">
|
||||
<EventsBarChart data={events} chartConfig={chartConfig} />
|
||||
<EventsBarChart
|
||||
data={
|
||||
chartData as Parameters<typeof EventsBarChart>[0]["data"]
|
||||
}
|
||||
chartConfig={chartConfig}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!events && !queryLoading && (
|
||||
{!chartData && !queryLoading && (
|
||||
<div className="flex-1 px-10 pt-6">
|
||||
<p className="text-t3 text-sm">
|
||||
No events found. Please widen your filters.{" "}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import { useAnalyticsContext } from "../AnalyticsContext";
|
||||
import { CustomerComboBox } from "./CustomerComboBox";
|
||||
import { SelectFeatureDropdown } from "./SelectFeatureDropdown";
|
||||
import { SelectGroupByDropdown } from "./SelectGroupByDropdown";
|
||||
|
||||
export const INTERVALS: Record<string, string> = {
|
||||
"24h": "Last 24 hours",
|
||||
@@ -23,8 +24,13 @@ export const INTERVALS: Record<string, string> = {
|
||||
};
|
||||
|
||||
export const QueryTopbar = () => {
|
||||
const { customer, selectedInterval, setSelectedInterval, bcExclusionFlag } =
|
||||
useAnalyticsContext();
|
||||
const {
|
||||
customer,
|
||||
selectedInterval,
|
||||
setSelectedInterval,
|
||||
bcExclusionFlag,
|
||||
propertyKeys,
|
||||
} = useAnalyticsContext();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
@@ -80,9 +86,17 @@ export const QueryTopbar = () => {
|
||||
</DropdownMenu>
|
||||
<SelectFeatureDropdown
|
||||
classNames={{
|
||||
trigger: "h-full border-y-0 border-l-0 border-r-1",
|
||||
trigger: "h-full border-y-0 border-l-0 border-r-0",
|
||||
}}
|
||||
/>
|
||||
{propertyKeys && propertyKeys.length > 0 && (
|
||||
<SelectGroupByDropdown
|
||||
propertyKeys={propertyKeys}
|
||||
classNames={{
|
||||
trigger: "h-full border-y-0 border-l-0 border-r-1",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { CaretDownIcon, MagnifyingGlassIcon } from "@phosphor-icons/react";
|
||||
import { Check } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/v2/dropdowns/DropdownMenu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAnalyticsContext } from "../AnalyticsContext";
|
||||
|
||||
export const SelectGroupByDropdown = ({
|
||||
propertyKeys,
|
||||
classNames,
|
||||
}: {
|
||||
propertyKeys: string[];
|
||||
classNames?: {
|
||||
trigger?: string;
|
||||
};
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const { groupFilter, setGroupFilter, availableGroupValues } =
|
||||
useAnalyticsContext();
|
||||
|
||||
const currentGroupBy = searchParams.get("group_by") || "";
|
||||
|
||||
const updateQueryParams = ({ groupBy }: { groupBy: string | null }) => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
|
||||
if (groupBy) {
|
||||
params.set("group_by", groupBy);
|
||||
} else {
|
||||
params.delete("group_by");
|
||||
}
|
||||
|
||||
navigate(`${location.pathname}?${params.toString()}`);
|
||||
};
|
||||
|
||||
const filteredOptions = propertyKeys.filter((key) =>
|
||||
key.toLowerCase().includes(searchValue.toLowerCase()),
|
||||
);
|
||||
|
||||
const handleSelect = ({ property }: { property: string | null }) => {
|
||||
updateQueryParams({ groupBy: property });
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const displayValue = currentGroupBy || "No grouping";
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
variant="secondary"
|
||||
size="default"
|
||||
icon={<CaretDownIcon size={12} weight="bold" />}
|
||||
iconOrientation="right"
|
||||
className={cn(classNames?.trigger, open && "btn-secondary-active")}
|
||||
>
|
||||
{currentGroupBy ? `Group: ${currentGroupBy}` : "Group By"}
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[200px]">
|
||||
{propertyKeys.length > 5 && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border">
|
||||
<MagnifyingGlassIcon className="size-4 text-t4" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search properties..."
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
className="flex-1 bg-transparent text-sm outline-none placeholder:text-t4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleSelect({ property: null })}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="text-xs">No grouping</span>
|
||||
{!currentGroupBy && <Check className="ml-2 h-3 w-3 text-t3" />}
|
||||
</DropdownMenuItem>
|
||||
|
||||
{propertyKeys.length > 0 && <DropdownMenuSeparator />}
|
||||
|
||||
{filteredOptions.length === 0 && propertyKeys.length > 0 && (
|
||||
<div className="py-4 text-center text-sm text-t4">
|
||||
No properties found
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredOptions.map((property) => (
|
||||
<DropdownMenuItem
|
||||
key={property}
|
||||
onClick={() => handleSelect({ property })}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<span className="text-xs font-mono">{property}</span>
|
||||
{currentGroupBy === property && (
|
||||
<Check className="ml-2 h-3 w-3 text-t3" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
|
||||
{/* Filter section - only shown when a groupBy is selected */}
|
||||
{currentGroupBy && availableGroupValues.length > 0 && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="text-xs text-t4 font-normal">
|
||||
Filter by value
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuItem
|
||||
onClick={() => setGroupFilter(null)}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<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>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export const useAnalyticsData = ({
|
||||
const featureIds = searchParams.get("feature_ids")?.split(",");
|
||||
const eventNames = searchParams.get("event_names")?.split(",");
|
||||
const interval = searchParams.get("interval");
|
||||
const groupBy = searchParams.get("group_by");
|
||||
|
||||
const { topEvents, isLoading: topEventsLoading } = useTopEventNames();
|
||||
|
||||
@@ -31,6 +32,9 @@ export const useAnalyticsData = ({
|
||||
const { features: featuresData, isLoading: featuresLoading } =
|
||||
useFeaturesQuery();
|
||||
|
||||
// Format group_by for API (must be prefixed with "properties.")
|
||||
const formattedGroupBy = groupBy ? `properties.${groupBy}` : undefined;
|
||||
|
||||
// Create a simple queryKey with the actual values that change
|
||||
const queryKey = [
|
||||
customerId,
|
||||
@@ -38,6 +42,7 @@ export const useAnalyticsData = ({
|
||||
...(eventNames || []).sort(),
|
||||
...(featureIds || []).sort(),
|
||||
org?.slug,
|
||||
groupBy,
|
||||
];
|
||||
|
||||
const {
|
||||
@@ -50,6 +55,7 @@ export const useAnalyticsData = ({
|
||||
customer_id: customerId || null,
|
||||
interval: interval || "30d",
|
||||
event_names: [...(eventNames || []), ...(featureIds || [])],
|
||||
group_by: formattedGroupBy,
|
||||
},
|
||||
queryKey: ["query-events", ...queryKey],
|
||||
options: {
|
||||
@@ -72,6 +78,7 @@ export const useAnalyticsData = ({
|
||||
error: error?.code === ErrCode.ClickHouseDisabled ? null : error,
|
||||
bcExclusionFlag: data?.bcExclusionFlag ?? false,
|
||||
topEventsLoading,
|
||||
groupBy,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Raw event structure from the analytics API
|
||||
*/
|
||||
interface RawEvent {
|
||||
timestamp: string;
|
||||
event_name: string;
|
||||
value: number;
|
||||
properties: Record<string, unknown> | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal properties to exclude from the group by dropdown
|
||||
*/
|
||||
const EXCLUDED_PROPERTIES = new Set(["value"]);
|
||||
|
||||
/**
|
||||
* Maximum number of events to scan for property keys
|
||||
*/
|
||||
const MAX_EVENTS_TO_SCAN = 1000;
|
||||
|
||||
/**
|
||||
* Extracts unique property keys from raw events for use in group by dropdown.
|
||||
* Scans up to 1000 events and filters out internal properties.
|
||||
*/
|
||||
export function extractPropertyKeys({
|
||||
rawEvents,
|
||||
}: {
|
||||
rawEvents: RawEvent[] | undefined;
|
||||
}): string[] {
|
||||
if (!rawEvents || rawEvents.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const propertyKeys = new Set<string>();
|
||||
const eventsToScan = rawEvents.slice(0, MAX_EVENTS_TO_SCAN);
|
||||
|
||||
for (const event of eventsToScan) {
|
||||
const properties = parseProperties(event.properties);
|
||||
if (!properties) continue;
|
||||
|
||||
for (const key of Object.keys(properties)) {
|
||||
if (!EXCLUDED_PROPERTIES.has(key)) {
|
||||
propertyKeys.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(propertyKeys).sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses properties which may be a string (JSON) or already an object
|
||||
*/
|
||||
function parseProperties(
|
||||
properties: Record<string, unknown> | string | undefined,
|
||||
): Record<string, unknown> | null {
|
||||
if (!properties) return null;
|
||||
|
||||
if (typeof properties === "string") {
|
||||
try {
|
||||
return JSON.parse(properties);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import type { Feature } from "@autumn/shared";
|
||||
import { FeatureType } from "@autumn/shared";
|
||||
|
||||
/**
|
||||
* Row data from the events API
|
||||
*/
|
||||
type EventRow = Record<string, string | number>;
|
||||
|
||||
/**
|
||||
* Events data structure from the API
|
||||
*/
|
||||
interface EventsData {
|
||||
meta: Array<{ name: string }>;
|
||||
rows: number;
|
||||
data: EventRow[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart series configuration
|
||||
*/
|
||||
interface ChartSeriesConfig {
|
||||
xKey: string;
|
||||
yKey: string;
|
||||
type: "bar";
|
||||
stacked: boolean;
|
||||
yName: string;
|
||||
fill: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart colors palette - more distinct colors for groups
|
||||
*/
|
||||
const CHART_COLORS = [
|
||||
"#9c5aff", // purple
|
||||
"#27a7ff", // blue
|
||||
"#10b981", // green
|
||||
"#f59e0b", // orange
|
||||
"#ef4444", // red
|
||||
"#ec4899", // pink
|
||||
"#06b6d4", // cyan
|
||||
"#8b5cf6", // violet
|
||||
"#14b8a6", // teal
|
||||
"#f97316", // orange-dark
|
||||
];
|
||||
|
||||
/**
|
||||
* Gets feature name for a given event/feature key
|
||||
*/
|
||||
function getFeatureName({
|
||||
key,
|
||||
features,
|
||||
}: {
|
||||
key: string;
|
||||
features: Feature[];
|
||||
}): string {
|
||||
const eventName = key.replace("_count", "");
|
||||
|
||||
const feature = features.find((f) => {
|
||||
if (f.type === FeatureType.Boolean) return false;
|
||||
if (f.id === eventName) return true;
|
||||
if (f.event_names && f.event_names.length > 0) {
|
||||
return f.event_names.includes(eventName);
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return feature?.name || eventName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms grouped data from backend format to chart-ready format.
|
||||
*
|
||||
* Backend returns (when group_by is used):
|
||||
* [
|
||||
* { period: "2024-01-01", "properties.platform": "ios", messages_count: 5 },
|
||||
* { period: "2024-01-01", "properties.platform": "android", messages_count: 3 },
|
||||
* ]
|
||||
*
|
||||
* Chart needs:
|
||||
* [
|
||||
* { period: "2024-01-01", "messages_count__ios": 5, "messages_count__android": 3 },
|
||||
* ]
|
||||
*/
|
||||
export function transformGroupedData({
|
||||
events,
|
||||
groupBy,
|
||||
}: {
|
||||
events: EventsData;
|
||||
groupBy: string | null;
|
||||
}): EventsData {
|
||||
if (!groupBy) {
|
||||
return events;
|
||||
}
|
||||
|
||||
const groupByColumn = `properties.${groupBy}`;
|
||||
|
||||
// Check if data has the group_by column
|
||||
const hasGroupColumn = events.meta.some((m) => m.name === groupByColumn);
|
||||
if (!hasGroupColumn) {
|
||||
return events;
|
||||
}
|
||||
|
||||
// Get feature columns (exclude period and group_by column)
|
||||
const featureColumns = events.meta
|
||||
.filter((m) => m.name !== "period" && m.name !== groupByColumn)
|
||||
.map((m) => m.name);
|
||||
|
||||
// Collect all unique group values
|
||||
const groupValues = new Set<string>();
|
||||
for (const row of events.data) {
|
||||
const groupValue = row[groupByColumn];
|
||||
if (groupValue !== undefined && groupValue !== null && groupValue !== "") {
|
||||
groupValues.add(String(groupValue));
|
||||
}
|
||||
}
|
||||
|
||||
// Pivot data: group by period and create columns for each group value
|
||||
const pivotedMap = new Map<
|
||||
string | number,
|
||||
Record<string, string | number>
|
||||
>();
|
||||
|
||||
for (const row of events.data) {
|
||||
const period = row.period;
|
||||
const groupValue = String(row[groupByColumn] || "unknown");
|
||||
|
||||
if (!pivotedMap.has(period)) {
|
||||
pivotedMap.set(period, { period });
|
||||
}
|
||||
|
||||
const pivotedRow = pivotedMap.get(period)!;
|
||||
|
||||
// Add each feature value with the group suffix
|
||||
for (const featureCol of featureColumns) {
|
||||
const newKey = `${featureCol}__${groupValue}`;
|
||||
pivotedRow[newKey] = row[featureCol] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure all group combinations exist (fill with 0)
|
||||
for (const pivotedRow of pivotedMap.values()) {
|
||||
for (const featureCol of featureColumns) {
|
||||
for (const groupValue of groupValues) {
|
||||
const key = `${featureCol}__${groupValue}`;
|
||||
if (pivotedRow[key] === undefined) {
|
||||
pivotedRow[key] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build new meta
|
||||
const newMeta: Array<{ name: string }> = [{ name: "period" }];
|
||||
for (const featureCol of featureColumns) {
|
||||
for (const groupValue of groupValues) {
|
||||
newMeta.push({ name: `${featureCol}__${groupValue}` });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
meta: newMeta,
|
||||
rows: pivotedMap.size,
|
||||
data: Array.from(pivotedMap.values()),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates chart configuration with different colors per group.
|
||||
*/
|
||||
export function generateChartConfig({
|
||||
events,
|
||||
features,
|
||||
groupBy,
|
||||
originalColors,
|
||||
}: {
|
||||
events: EventsData;
|
||||
features: Feature[];
|
||||
groupBy: string | null;
|
||||
originalColors: string[];
|
||||
}): ChartSeriesConfig[] {
|
||||
const colorsToUse = groupBy ? CHART_COLORS : originalColors;
|
||||
|
||||
if (!groupBy) {
|
||||
// Non-grouped: original behavior
|
||||
return events.meta
|
||||
.filter((m) => m.name !== "period")
|
||||
.map((m, index) => ({
|
||||
xKey: "period",
|
||||
yKey: m.name,
|
||||
type: "bar" as const,
|
||||
stacked: true,
|
||||
yName: getFeatureName({ key: m.name, features }),
|
||||
fill: colorsToUse[index % colorsToUse.length],
|
||||
}));
|
||||
}
|
||||
|
||||
// Grouped: create series for each feature__group combination
|
||||
const config: ChartSeriesConfig[] = [];
|
||||
let colorIndex = 0;
|
||||
|
||||
for (const meta of events.meta) {
|
||||
if (meta.name === "period") continue;
|
||||
|
||||
// Parse feature__groupValue format
|
||||
const parts = meta.name.split("__");
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const featureKey = parts.slice(0, -1).join("__"); // Handle feature names with underscores
|
||||
const groupValue = parts[parts.length - 1];
|
||||
|
||||
const featureName = getFeatureName({ key: featureKey, features });
|
||||
|
||||
config.push({
|
||||
xKey: "period",
|
||||
yKey: meta.name,
|
||||
type: "bar",
|
||||
stacked: true,
|
||||
yName: `${featureName} (${groupValue})`,
|
||||
fill: colorsToUse[colorIndex % colorsToUse.length],
|
||||
});
|
||||
|
||||
colorIndex++;
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { FingerprintIcon, Ticket } from "@phosphor-icons/react";
|
||||
import { FingerprintIcon, TicketIcon } from "@phosphor-icons/react";
|
||||
import { CopyButton } from "@/components/v2/buttons/CopyButton";
|
||||
import { useCusReferralQuery } from "@/views/customers/customer/hooks/useCusReferralQuery";
|
||||
import { CustomerActions } from "./CustomerActions";
|
||||
import { useCustomerContext } from "./CustomerContext";
|
||||
|
||||
const mutedDivClassName =
|
||||
"py-0.5 px-1.5 rounded-lg text-t3 text-tiny flex items-center gap-1 h-6 max-w-48 truncate ";
|
||||
"py-0.5 px-1.5 rounded-lg text-t3 text-tiny flex items-center gap-2 h-6 max-w-48 truncate bg-muted text-tiny-id";
|
||||
|
||||
const placeholderText = "NULL";
|
||||
|
||||
@@ -13,7 +13,7 @@ export const CustomerPageDetails = () => {
|
||||
const { customer } = useCustomerContext();
|
||||
const { stripeCus } = useCusReferralQuery();
|
||||
|
||||
const appliedCoupon = stripeCus?.discount?.coupon;
|
||||
const appliedCoupon = stripeCus?.discount?.source;
|
||||
|
||||
return (
|
||||
<div className="flex h-4 items-center">
|
||||
@@ -48,9 +48,9 @@ export const CustomerPageDetails = () => {
|
||||
</div>
|
||||
)}
|
||||
{appliedCoupon && (
|
||||
<div className="py-0.5 px-1.5 bg-secondary rounded-lg text-t3 text-sm flex items-center gap-1 h-6 max-w-48 truncate">
|
||||
<Ticket size={12} className="shrink-0" />
|
||||
<span className="truncate">{appliedCoupon.name}</span>
|
||||
<div className={mutedDivClassName}>
|
||||
<TicketIcon size={13} className="shrink-0" />
|
||||
<span className="truncate">{appliedCoupon.coupon}</span>
|
||||
</div>
|
||||
)}
|
||||
<CustomerActions />
|
||||
|
||||
Reference in New Issue
Block a user