projects overview and event list improvements
This commit is contained in:
@@ -45,10 +45,12 @@
|
||||
"@trpc/next": "^10.45.1",
|
||||
"@trpc/react-query": "^10.45.1",
|
||||
"@trpc/server": "^10.45.1",
|
||||
"@types/d3": "^7.4.3",
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^0.2.1",
|
||||
"d3": "^7.8.5",
|
||||
"date-fns": "^3.3.1",
|
||||
"embla-carousel-react": "8.0.0-rc22",
|
||||
"flag-icons": "^7.1.0",
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
'use client';
|
||||
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { ChartSwitch, ChartSwitchShortcut } from '@/components/report/chart';
|
||||
import { Chart } from '@/components/report/chart/Chart';
|
||||
import { ChartSwitchShortcut } from '@/components/report/chart';
|
||||
import { KeyValue } from '@/components/ui/key-value';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet';
|
||||
import { useEventQueryFilters } from '@/hooks/useEventQueryFilters';
|
||||
import {
|
||||
useEventQueryFilters,
|
||||
useEventQueryNamesFilter,
|
||||
} from '@/hooks/useEventQueryFilters';
|
||||
import { round } from 'mathjs';
|
||||
|
||||
import type { IServiceCreateEventPayload } from '@mixan/db';
|
||||
@@ -24,6 +25,8 @@ interface Props {
|
||||
export function EventDetails({ event, open, setOpen }: Props) {
|
||||
const { name } = event;
|
||||
const [, setFilter] = useEventQueryFilters({ shallow: false });
|
||||
const [, setEvents] = useEventQueryNamesFilter({ shallow: false });
|
||||
|
||||
const common = [
|
||||
{
|
||||
name: 'Duration',
|
||||
@@ -138,8 +141,8 @@ export function EventDetails({ event, open, setOpen }: Props) {
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={setOpen}>
|
||||
<SheetContent className="overflow-y-scroll">
|
||||
<div className="overflow-y-scroll">
|
||||
<SheetContent>
|
||||
<div>
|
||||
<div className="flex flex-col gap-8">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{name.replace('_', ' ')}</SheetTitle>
|
||||
@@ -181,7 +184,18 @@ export function EventDetails({ event, open, setOpen }: Props) {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm font-medium mb-2">Similar events</div>
|
||||
<div className="flex justify-between text-sm font-medium mb-2">
|
||||
<div>Similar events</div>
|
||||
<button
|
||||
className="hover:underline text-muted-foreground"
|
||||
onClick={() => {
|
||||
setEvents([event.name]);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Show all
|
||||
</button>
|
||||
</div>
|
||||
<ChartSwitchShortcut
|
||||
projectId={event.projectId}
|
||||
chartType="histogram"
|
||||
|
||||
@@ -157,6 +157,7 @@ export function EventEdit({ event, open, setOpen }: Props) {
|
||||
|
||||
<SheetFooter>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() =>
|
||||
mutation.mutate({
|
||||
projectId,
|
||||
|
||||
@@ -61,7 +61,7 @@ export function EventListItem(props: EventListItemProps) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsDetailsOpen(true)}
|
||||
className="font-semibold hover:underline"
|
||||
className="text-left font-semibold hover:underline"
|
||||
>
|
||||
{name.replace(/_/g, ' ')}
|
||||
</button>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function EventList({ data, count }: EventListProps) {
|
||||
</FullPageEmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<div className="flex flex-col md:flex-row justify-between gap-2">
|
||||
<EventListener />
|
||||
<Pagination
|
||||
cursor={cursor}
|
||||
@@ -65,7 +65,7 @@ export function EventList({ data, count }: EventListProps) {
|
||||
take={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col my-4 card p-4">
|
||||
<div className="flex flex-col my-4 card p-4 gap-0.5">
|
||||
{data.map((item, index, list) => (
|
||||
<Fragment key={item.id}>
|
||||
{showDateHeader(item.createdAt, list[index - 1]?.createdAt) && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
getCurrentOrganizations,
|
||||
getDashboardsByOrganization,
|
||||
getDashboardsByProjectId,
|
||||
} from '@mixan/db';
|
||||
|
||||
import { LayoutSidebar } from './layout-sidebar';
|
||||
@@ -19,7 +20,7 @@ export default async function AppLayout({
|
||||
}: AppLayoutProps) {
|
||||
const [organizations, dashboards] = await Promise.all([
|
||||
getCurrentOrganizations(),
|
||||
getDashboardsByOrganization(organizationId),
|
||||
getDashboardsByProjectId(projectId),
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function ReportEditor({
|
||||
<ChartSwitch {...report} projectId={projectId} editMode />
|
||||
)}
|
||||
</div>
|
||||
<SheetContent className="!max-w-lg w-full" side="left">
|
||||
<SheetContent className="!max-w-lg" side="left">
|
||||
<ReportSidebar />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { LogoSquare } from '@/components/Logo';
|
||||
import { ProjectCard } from '@/components/projects/project-card';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
|
||||
import { getOrganizationBySlug, getProjectWithMostEvents } from '@mixan/db';
|
||||
import {
|
||||
getOrganizationBySlug,
|
||||
getProjectsByOrganizationSlug,
|
||||
} from '@mixan/db';
|
||||
|
||||
import { CreateProject } from './create-project';
|
||||
|
||||
@@ -12,9 +16,9 @@ interface PageProps {
|
||||
}
|
||||
|
||||
export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
const [organization, project] = await Promise.all([
|
||||
const [organization, projects] = await Promise.all([
|
||||
getOrganizationBySlug(organizationId),
|
||||
getProjectWithMostEvents(organizationId),
|
||||
getProjectsByOrganizationSlug(organizationId),
|
||||
]);
|
||||
|
||||
if (!organization) {
|
||||
@@ -36,15 +40,26 @@ export default async function Page({ params: { organizationId } }: PageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (project) {
|
||||
return redirect(`/${organizationId}/${project.id}`);
|
||||
if (projects.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="max-w-lg w-full">
|
||||
<CreateProject />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projects.length === 1 && projects[0]) {
|
||||
return redirect(`/${organizationId}/${projects[0].id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
<div className="max-w-lg w-full">
|
||||
<CreateProject />
|
||||
</div>
|
||||
<div className="max-w-xl w-full mx-auto flex flex-col gap-4 pt-20">
|
||||
<h1 className="font-medium text-xl">Select project</h1>
|
||||
{projects.map((item) => (
|
||||
<ProjectCard key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
68
apps/web/src/components/chart-ssr.tsx
Normal file
68
apps/web/src/components/chart-ssr.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import * as d3 from 'd3';
|
||||
|
||||
export function ChartSSR({
|
||||
data,
|
||||
dots = false,
|
||||
}: {
|
||||
dots?: boolean;
|
||||
data: { value: number; date: Date }[];
|
||||
}) {
|
||||
const xScale = d3
|
||||
.scaleTime()
|
||||
.domain([data[0]!.date, data[data.length - 1]!.date])
|
||||
.range([0, 100]);
|
||||
const yScale = d3
|
||||
.scaleLinear()
|
||||
.domain([0, d3.max(data.map((d) => d.value)) ?? 0])
|
||||
.range([100, 0]);
|
||||
|
||||
const line = d3
|
||||
.line<(typeof data)[number]>()
|
||||
.curve(d3.curveMonotoneX)
|
||||
.x((d) => xScale(d.date))
|
||||
.y((d) => yScale(d.value));
|
||||
|
||||
const d = line(data);
|
||||
|
||||
if (!d) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="@container relative h-full w-full">
|
||||
{/* Chart area */}
|
||||
<svg className="absolute inset-0 h-full w-full overflow-visible">
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
className="overflow-visible"
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
{/* Line */}
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
className="text-blue-600"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
|
||||
{/* Circles */}
|
||||
{dots &&
|
||||
data.map((d) => (
|
||||
<path
|
||||
key={d.date.toString()}
|
||||
d={`M ${xScale(d.date)} ${yScale(d.value)} l 0.0001 0`}
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="text-gray-400"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
apps/web/src/components/projects/project-card.tsx
Normal file
68
apps/web/src/components/projects/project-card.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { shortNumber } from '@/hooks/useNumerFormatter';
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { IServiceProject } from '@mixan/db';
|
||||
import { chQuery } from '@mixan/db';
|
||||
|
||||
import { ChartSSR } from '../chart-ssr';
|
||||
|
||||
export async function ProjectCard({
|
||||
id,
|
||||
name,
|
||||
organizationSlug,
|
||||
}: IServiceProject) {
|
||||
const [chart, [data]] = await Promise.all([
|
||||
chQuery<{ value: number; date: string }>(
|
||||
`SELECT countDistinct(profile_id) as value, toStartOfDay(created_at) as date FROM events WHERE project_id = '${id}' AND name = 'session_start' AND created_at >= now() - interval '1 month' GROUP BY date ORDER BY date ASC`
|
||||
),
|
||||
chQuery<{ total: number; month: number; day: number }>(
|
||||
`
|
||||
SELECT
|
||||
(
|
||||
SELECT count(DISTINCT profile_id) as count FROM events WHERE project_id = '${id}'
|
||||
) as total,
|
||||
(
|
||||
SELECT count(DISTINCT profile_id) as count FROM events WHERE project_id = '${id}' AND created_at >= now() - interval '1 month'
|
||||
) as month,
|
||||
(
|
||||
SELECT count(DISTINCT profile_id) as count FROM events WHERE project_id = '${id}' AND created_at >= now() - interval '1 day'
|
||||
) as day
|
||||
`
|
||||
),
|
||||
]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/${organizationSlug}/${id}`}
|
||||
className="card p-4 inline-flex flex-col gap-2 hover:-translate-y-1 transition-transform"
|
||||
>
|
||||
<div className="font-medium">{name}</div>
|
||||
<div className="aspect-[15/1] -mx-4">
|
||||
<ChartSSR data={chart.map((d) => ({ ...d, date: new Date(d.date) }))} />
|
||||
</div>
|
||||
<div className="flex gap-4 justify-between text-muted-foreground text-sm">
|
||||
<div className="font-medium">Visitors</div>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<div>Total</div>
|
||||
<span className="text-black font-medium">
|
||||
{shortNumber('en')(data?.total)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<div>Month</div>
|
||||
<span className="text-black font-medium">
|
||||
{shortNumber('en')(data?.month)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col md:flex-row gap-2">
|
||||
<div>24h</div>
|
||||
<span className="text-black font-medium">
|
||||
{shortNumber('en')(data?.day)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { SheetClose } from '@/components/ui/sheet';
|
||||
import { SheetClose, SheetFooter } from '@/components/ui/sheet';
|
||||
import { useSelector } from '@/redux';
|
||||
|
||||
import { ReportBreakdowns } from './ReportBreakdowns';
|
||||
@@ -11,15 +11,17 @@ export function ReportSidebar() {
|
||||
const showForumula = chartType !== 'funnel';
|
||||
const showBreakdown = chartType !== 'funnel';
|
||||
return (
|
||||
<div className="flex flex-col gap-8 pb-12">
|
||||
<ReportEvents />
|
||||
{showForumula && <ReportForumula />}
|
||||
{showBreakdown && <ReportBreakdowns />}
|
||||
<div className="absolute bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-white/100 to-white/0">
|
||||
<>
|
||||
<div className="flex flex-col gap-8">
|
||||
<ReportEvents />
|
||||
{showForumula && <ReportForumula />}
|
||||
{showBreakdown && <ReportBreakdowns />}
|
||||
</div>
|
||||
<SheetFooter>
|
||||
<SheetClose asChild>
|
||||
<Button className="w-full">Done</Button>
|
||||
</SheetClose>
|
||||
</div>
|
||||
</div>
|
||||
</SheetFooter>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,14 +31,14 @@ const SheetOverlay = React.forwardRef<
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
const sheetVariants = cva(
|
||||
'overflow-y-auto fixed z-50 gap-4 bg-background p-6 rounded-lg shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
'flex flex-col max-sm:w-[calc(100%-theme(spacing.8))] overflow-y-auto fixed z-50 gap-4 bg-background p-6 rounded-lg shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500',
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: 'inset-x-4 top-4 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||
bottom:
|
||||
'inset-x-4 bottom-4 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||
left: 'top-4 bottom-4 left-4 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
left: 'top-4 bottom-4 left-4 w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||
right:
|
||||
'top-4 bottom-4 right-4 w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm',
|
||||
},
|
||||
@@ -101,6 +101,7 @@ const SheetFooter = ({
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
|
||||
'sticky bottom-0 left-0 right-0 mt-auto',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -8,10 +8,8 @@ export function fancyMinutes(time: number) {
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
export function useNumber() {
|
||||
const locale = 'en-gb';
|
||||
|
||||
const format = (value: number | null | undefined) => {
|
||||
export const formatNumber =
|
||||
(locale: string) => (value: number | null | undefined) => {
|
||||
if (isNil(value)) {
|
||||
return 'N/A';
|
||||
}
|
||||
@@ -19,7 +17,9 @@ export function useNumber() {
|
||||
maximumSignificantDigits: 20,
|
||||
}).format(value);
|
||||
};
|
||||
const short = (value: number | null | undefined) => {
|
||||
|
||||
export const shortNumber =
|
||||
(locale: string) => (value: number | null | undefined) => {
|
||||
if (isNil(value)) {
|
||||
return 'N/A';
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export function useNumber() {
|
||||
}).format(value);
|
||||
};
|
||||
|
||||
export function useNumber() {
|
||||
const locale = 'en-gb';
|
||||
const format = formatNumber(locale);
|
||||
const short = shortNumber(locale);
|
||||
return {
|
||||
format,
|
||||
short,
|
||||
|
||||
Reference in New Issue
Block a user