Files
stats/apps/start/src/modals/view-chart-users.tsx
Carl-Gerhard Lindesvärd 57697a5a39 wip
2025-11-22 00:05:13 +01:00

400 lines
12 KiB
TypeScript

import { ProjectLink } from '@/components/links';
import { ProfileAvatar } from '@/components/profiles/profile-avatar';
import { SerieIcon } from '@/components/report-chart/common/serie-icon';
import { DropdownMenuShortcut } from '@/components/ui/dropdown-menu';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useTRPC } from '@/integrations/trpc/react';
import type { IChartData } from '@/trpc/client';
import { cn } from '@/utils/cn';
import { getProfileName } from '@/utils/getters';
import type { IChartInput } from '@openpanel/validation';
import { useQuery } from '@tanstack/react-query';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useEffect, useMemo, useState } from 'react';
import { popModal } from '.';
import { ModalHeader } from './Modal/Container';
import { ScrollableModal, useScrollableModal } from './Modal/scrollable-modal';
const ProfileItem = ({ profile }: { profile: any }) => {
console.log('ProfileItem', profile.id);
return (
<ProjectLink
preload={false}
href={`/profiles/${profile.id}`}
title={getProfileName(profile, false)}
className="col gap-2 rounded-lg border p-2 bg-card"
onClick={(e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey) {
return;
}
popModal();
}}
>
<div className="row gap-2 items-center">
<ProfileAvatar {...profile} />
<div className="flex-1">
<div className="font-medium">{getProfileName(profile)}</div>
</div>
</div>
<div className="row gap-4 text-sm overflow-hidden">
{profile.properties.country && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.properties.country} />
<span>
{profile.properties.country}
{profile.properties.city && ` / ${profile.properties.city}`}
</span>
</div>
)}
{profile.properties.os && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.properties.os} />
<span>{profile.properties.os}</span>
</div>
)}
{profile.properties.browser && (
<div className="row gap-2 items-center">
<SerieIcon name={profile.properties.browser} />
<span>{profile.properties.browser}</span>
</div>
)}
</div>
</ProjectLink>
);
};
// Shared profile list component
function ProfileList({ profiles }: { profiles: any[] }) {
const ITEM_HEIGHT = 74;
const CONTAINER_PADDING = 20;
const ITEM_GAP = 5;
const { scrollAreaRef } = useScrollableModal();
const [isScrollReady, setIsScrollReady] = useState(false);
// Check if scroll container is ready
useEffect(() => {
if (scrollAreaRef.current) {
setIsScrollReady(true);
} else {
setIsScrollReady(false);
}
}, [scrollAreaRef]);
const virtualizer = useVirtualizer({
count: profiles.length,
getScrollElement: () => scrollAreaRef.current,
estimateSize: () => ITEM_HEIGHT + ITEM_GAP,
overscan: 5,
paddingStart: CONTAINER_PADDING,
paddingEnd: CONTAINER_PADDING,
});
// Re-measure when scroll container becomes available or profiles change
useEffect(() => {
if (isScrollReady && scrollAreaRef.current) {
// Small delay to ensure DOM is ready
const timeoutId = setTimeout(() => {
virtualizer.measure();
}, 0);
return () => clearTimeout(timeoutId);
}
}, [isScrollReady, profiles.length, virtualizer]);
if (profiles.length === 0) {
return (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">No users found</div>
</div>
);
}
const virtualItems = virtualizer.getVirtualItems();
return (
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{/* Only the visible items in the virtualizer, manually positioned to be in view */}
{virtualItems.map((virtualItem) => {
const profile = profiles[virtualItem.index];
return (
<div
key={profile.id}
data-index={virtualItem.index}
ref={virtualizer.measureElement}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualItem.size}px`,
transform: `translateY(${virtualItem.start}px)`,
padding: `0px ${CONTAINER_PADDING}px ${ITEM_GAP}px`,
}}
>
<ProfileItem profile={profile} />
</div>
);
})}
</div>
);
}
// Chart-specific props and component
interface ChartUsersViewProps {
chartData: IChartData;
report: IChartInput;
date: string;
}
function ChartUsersView({ chartData, report, date }: ChartUsersViewProps) {
const trpc = useTRPC();
const [selectedSerieId, setSelectedSerieId] = useState<string | null>(
report.series[0]?.id || null,
);
const [selectedBreakdownId, setSelectedBreakdownId] = useState<string | null>(
null,
);
const selectedReportSerie = useMemo(
() => report.series.find((s) => s.id === selectedSerieId),
[report.series, selectedSerieId],
);
// Get all chart series that match the selected report serie
const matchingChartSeries = useMemo(() => {
if (!selectedSerieId || !chartData) return [];
return chartData.series.filter((s) => s.event.id === selectedSerieId);
}, [chartData?.series, selectedSerieId]);
const selectedBreakdown = useMemo(() => {
if (!selectedBreakdownId) return null;
return matchingChartSeries.find((s) => s.id === selectedBreakdownId);
}, [matchingChartSeries, selectedBreakdownId]);
// Reset breakdown selection when serie changes
const handleSerieChange = (value: string) => {
setSelectedSerieId(value);
setSelectedBreakdownId(null);
};
const profilesQuery = useQuery(
trpc.chart.getProfiles.queryOptions(
{
projectId: report.projectId,
date: date,
series:
selectedReportSerie && selectedReportSerie.type === 'event'
? [selectedReportSerie]
: [],
breakdowns: selectedBreakdown?.event.breakdowns,
interval: report.interval,
},
{
enabled: !!selectedReportSerie && selectedReportSerie.type === 'event',
},
),
);
const profiles = profilesQuery.data ?? [];
return (
<ScrollableModal
header={
<div>
<ModalHeader
title="View Users"
text={`Users who performed actions on ${new Date(date).toLocaleDateString()}`}
/>
{report.series.length > 0 && (
<div className="col md:row gap-2">
<Select
value={selectedSerieId || ''}
onValueChange={handleSerieChange}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select Serie" />
</SelectTrigger>
<SelectContent>
{report.series.map((serie) => (
<SelectItem key={serie.id} value={serie.id || ''}>
{serie.type === 'event'
? serie.displayName || serie.name
: serie.displayName || 'Formula'}
</SelectItem>
))}
</SelectContent>
</Select>
{matchingChartSeries.length > 1 && (
<Select
value={selectedBreakdownId || ''}
onValueChange={(value) => setSelectedBreakdownId(value)}
>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Select Breakdown" />
</SelectTrigger>
<SelectContent>
{matchingChartSeries
.sort((a, b) => b.metrics.sum - a.metrics.sum)
.map((serie) => (
<SelectItem key={serie.id} value={serie.id}>
{Object.values(serie.event.breakdowns ?? {}).join(
', ',
)}
<DropdownMenuShortcut className="ml-auto">
({serie.data.find((d) => d.date === date)?.count})
</DropdownMenuShortcut>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
)}
</div>
}
>
<div className="col">
{profilesQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">Loading users...</div>
</div>
) : (
<ProfileList profiles={profiles} />
)}
</div>
</ScrollableModal>
);
}
// Funnel-specific props and component
interface FunnelUsersViewProps {
report: IChartInput;
stepIndex: number;
}
function FunnelUsersView({ report, stepIndex }: FunnelUsersViewProps) {
const trpc = useTRPC();
const [showDropoffs, setShowDropoffs] = useState(false);
const profilesQuery = useQuery(
trpc.chart.getFunnelProfiles.queryOptions(
{
projectId: report.projectId,
startDate: report.startDate!,
endDate: report.endDate!,
range: report.range,
series: report.series,
stepIndex: stepIndex,
showDropoffs: showDropoffs,
funnelWindow: report.funnelWindow,
funnelGroup: report.funnelGroup,
breakdowns: report.breakdowns,
},
{
enabled: stepIndex !== undefined,
},
),
);
const profiles = profilesQuery.data ?? [];
const isLastStep = stepIndex === report.series.length - 1;
return (
<ScrollableModal
header={
<div className="flex flex-col gap-2">
<ModalHeader
title="View Users"
text={
showDropoffs
? `Users who dropped off after step ${stepIndex + 1} of ${report.series.length}`
: `Users who completed step ${stepIndex + 1} of ${report.series.length} in the funnel`
}
/>
{!isLastStep && (
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setShowDropoffs(false)}
className={cn(
'px-3 py-1.5 text-sm rounded-md transition-colors',
!showDropoffs
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
>
Completed
</button>
<button
type="button"
onClick={() => setShowDropoffs(true)}
className={cn(
'px-3 py-1.5 text-sm rounded-md transition-colors',
showDropoffs
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/80',
)}
>
Dropped Off
</button>
</div>
)}
</div>
}
>
<div className="flex flex-col gap-4">
{profilesQuery.isLoading ? (
<div className="flex items-center justify-center py-8">
<div className="text-muted-foreground">Loading users...</div>
</div>
) : (
<ProfileList profiles={profiles} />
)}
</div>
</ScrollableModal>
);
}
// Union type for props
type ViewChartUsersProps =
| {
type: 'chart';
chartData: IChartData;
report: IChartInput;
date: string;
}
| {
type: 'funnel';
report: IChartInput;
stepIndex: number;
};
// Main component that routes to the appropriate view
export default function ViewChartUsers(props: ViewChartUsersProps) {
if (props.type === 'funnel') {
return (
<FunnelUsersView report={props.report} stepIndex={props.stepIndex} />
);
}
return (
<ChartUsersView
chartData={props.chartData}
report={props.report}
date={props.date}
/>
);
}