projects overview and event list improvements

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-03-09 21:53:40 +01:00
parent 79d2368cfc
commit faafb71d88
16 changed files with 585 additions and 59 deletions

View File

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

View File

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

View File

@@ -157,6 +157,7 @@ export function EventEdit({ event, open, setOpen }: Props) {
<SheetFooter>
<Button
className="w-full"
onClick={() =>
mutation.mutate({
projectId,

View File

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

View File

@@ -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) && (

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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