web: added interval minute, profile list and profile view

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-11-01 20:36:19 +01:00
parent 5a4765526b
commit 09672b34a3
19 changed files with 394 additions and 136 deletions

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Interval" ADD VALUE 'minute';

View File

@@ -109,6 +109,7 @@ enum Interval {
hour hour
day day
month month
minute
} }
enum ChartType { enum ChartType {

View File

@@ -16,7 +16,9 @@ export function usePagination(take = 100) {
); );
} }
export function Pagination(props: ReturnType<typeof usePagination>) { export type PaginationProps = ReturnType<typeof usePagination>
export function Pagination(props: PaginationProps) {
return ( return (
<div className="flex select-none items-center justify-end space-x-2 py-4"> <div className="flex select-none items-center justify-end space-x-2 py-4">
<Button <Button

View File

@@ -0,0 +1,99 @@
import { useMemo } from "react";
import { DataTable } from "@/components/DataTable";
import { type PaginationProps, Pagination } from "@/components/Pagination";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import { type RouterOutputs } from "@/utils/api";
import { formatDateTime } from "@/utils/date";
import { toDots } from "@/utils/object";
import { AvatarImage } from "@radix-ui/react-avatar";
import { createColumnHelper } from "@tanstack/react-table";
import Link from "next/link";
import { useOrganizationParams } from "@/hooks/useOrganizationParams";
const columnHelper =
createColumnHelper<RouterOutputs["event"]["list"][number]>();
type EventsTableProps = {
data: RouterOutputs["event"]["list"];
pagination: PaginationProps;
};
export function EventsTable({ data, pagination }: EventsTableProps) {
const params = useOrganizationParams()
const columns = useMemo(() => {
return [
columnHelper.accessor((row) => row.createdAt, {
id: "createdAt",
header: () => "Created At",
cell(info) {
return formatDateTime(info.getValue());
},
footer: () => "Created At",
}),
columnHelper.accessor((row) => row.name, {
id: "event",
header: () => "Event",
cell(info) {
return <span className="font-medium">{info.getValue()}</span>;
},
footer: () => "Created At",
}),
columnHelper.accessor((row) => row.profile, {
id: "profile",
header: () => "Profile",
cell(info) {
const profile = info.getValue();
return (
<Link href={`/${params.organization}/${params.project}/profiles/${profile?.id}`} className="flex items-center gap-2">
<Avatar className="h-6 w-6">
{profile?.avatar && <AvatarImage src={profile.avatar} />}
<AvatarFallback className="text-xs">
{profile?.first_name?.at(0)}
</AvatarFallback>
</Avatar>
{`${profile?.first_name} ${profile?.last_name ?? ""}`}
</Link>
);
},
footer: () => "Created At",
}),
columnHelper.accessor((row) => row.properties, {
id: "properties",
header: () => "Properties",
cell(info) {
const dots = toDots(info.getValue() as Record<string, any>);
return (
<Table className="mini">
<TableBody>
{Object.keys(dots).map((key) => {
return (
<TableRow key={key}>
<TableCell className="font-medium">{key}</TableCell>
<TableCell>
{typeof dots[key] === "boolean"
? dots[key]
? "true"
: "false"
: dots[key]}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
},
footer: () => "Created At",
}),
];
}, []);
return (
<>
<Pagination {...pagination} />
<DataTable data={data} columns={columns} />
<Pagination {...pagination} />
</>
);
}

View File

@@ -7,6 +7,8 @@ export function NavbarMenu() {
return ( return (
<div className="flex gap-6 items-center"> <div className="flex gap-6 items-center">
<Link href={`/${params.organization}`}>Home</Link> <Link href={`/${params.organization}`}>Home</Link>
{params.project && <Link href={`/${params.organization}/${params.project}/events`}>Events</Link>}
{params.project && <Link href={`/${params.organization}/${params.project}/profiles`}>Profiles</Link>}
<NavbarCreate /> <NavbarCreate />
</div> </div>
); );

View File

@@ -3,7 +3,7 @@ import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { changeDateRanges, changeInterval } from "./reportSlice"; import { changeDateRanges, changeInterval } from "./reportSlice";
import { Combobox } from "../ui/combobox"; import { Combobox } from "../ui/combobox";
import { type IInterval } from "@/types"; import { type IInterval } from "@/types";
import { timeRanges } from "@/utils/constants"; import { intervals, timeRanges } from "@/utils/constants";
export function ReportDateRange() { export function ReportDateRange() {
const dispatch = useDispatch(); const dispatch = useDispatch();
@@ -14,7 +14,7 @@ export function ReportDateRange() {
return ( return (
<> <>
<RadioGroup> <RadioGroup>
{timeRanges.map(item => { {timeRanges.map((item) => {
return ( return (
<RadioGroupItem <RadioGroupItem
key={item.range} key={item.range}
@@ -25,7 +25,7 @@ export function ReportDateRange() {
> >
{item.title} {item.title}
</RadioGroupItem> </RadioGroupItem>
) );
})} })}
</RadioGroup> </RadioGroup>
{chartType === "linear" && ( {chartType === "linear" && (
@@ -36,20 +36,10 @@ export function ReportDateRange() {
dispatch(changeInterval(value as IInterval)); dispatch(changeInterval(value as IInterval));
}} }}
value={interval} value={interval}
items={[ items={Object.entries(intervals).map(([key, value]) => ({
{ label: value,
label: "Hour", value: key,
value: "hour", }))}
},
{
label: "Day",
value: "day",
},
{
label: "Month",
value: "month",
},
]}
/> />
</div> </div>
)} )}

View File

@@ -1,6 +1,7 @@
import { useFormatDateInterval } from "@/hooks/useFormatDateInterval";
import { useMappings } from "@/hooks/useMappings"; import { useMappings } from "@/hooks/useMappings";
import { useSelector } from "@/redux";
import { type IToolTipProps } from "@/types"; import { type IToolTipProps } from "@/types";
import { formatDate } from "@/utils/date";
type ReportLineChartTooltipProps = IToolTipProps<{ type ReportLineChartTooltipProps = IToolTipProps<{
color: string; color: string;
@@ -17,6 +18,8 @@ export function ReportLineChartTooltip({
payload, payload,
}: ReportLineChartTooltipProps) { }: ReportLineChartTooltipProps) {
const getLabel = useMappings(); const getLabel = useMappings();
const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval(interval);
if (!active || !payload) { if (!active || !payload) {
return null; return null;

View File

@@ -117,7 +117,9 @@ export const reportSlice = createSlice({
changeDateRanges: (state, action: PayloadAction<IChartRange>) => { changeDateRanges: (state, action: PayloadAction<IChartRange>) => {
state.range = action.payload state.range = action.payload
if (action.payload === 0 || action.payload === 1) { if (action.payload === 0.3 || action.payload === 0.6) {
state.interval = "minute";
} else if (action.payload === 0 || action.payload === 1) {
state.interval = "hour"; state.interval = "hour";
} else if (action.payload <= 30) { } else if (action.payload <= 30) {
state.interval = "day"; state.interval = "day";

View File

@@ -2,7 +2,7 @@ import { type IInterval } from "@/types";
export function formatDateInterval(interval: IInterval, date: Date): string { export function formatDateInterval(interval: IInterval, date: Date): string {
if (interval === "hour") { if (interval === "hour" || interval === "minute") {
return new Intl.DateTimeFormat("en-GB", { return new Intl.DateTimeFormat("en-GB", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
@@ -14,7 +14,7 @@ export function formatDateInterval(interval: IInterval, date: Date): string {
} }
if (interval === "day") { if (interval === "day") {
return new Intl.DateTimeFormat("en-GB", { weekday: "short" }).format( return new Intl.DateTimeFormat("en-GB", { weekday: "short", day: '2-digit', month: '2-digit' }).format(
date, date,
); );
} }

View File

@@ -7,6 +7,7 @@ export function useOrganizationParams() {
organization: z.string(), organization: z.string(),
project: z.string(), project: z.string(),
dashboard: z.string(), dashboard: z.string(),
profileId: z.string().optional(),
}), }),
); );
} }

View File

@@ -1,20 +1,12 @@
import { Container } from "@/components/Container"; import { Container } from "@/components/Container";
import { DataTable } from "@/components/DataTable";
import { PageTitle } from "@/components/PageTitle"; import { PageTitle } from "@/components/PageTitle";
import { Pagination, usePagination } from "@/components/Pagination"; import { usePagination } from "@/components/Pagination";
import { EventsTable } from "@/components/events/EventsTable";
import { MainLayout } from "@/components/layouts/MainLayout"; import { MainLayout } from "@/components/layouts/MainLayout";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import { useOrganizationParams } from "@/hooks/useOrganizationParams"; import { useOrganizationParams } from "@/hooks/useOrganizationParams";
import { type RouterOutputs, api } from "@/utils/api"; import { api } from "@/utils/api";
import { formatDateTime } from "@/utils/date";
import { toDots } from "@/utils/object";
import { AvatarImage } from "@radix-ui/react-avatar";
import { createColumnHelper } from "@tanstack/react-table";
import { useMemo } from "react";
const columnHelper = import { useMemo } from "react";
createColumnHelper<RouterOutputs["event"]["list"][number]>();
export default function Events() { export default function Events() {
const pagination = usePagination(); const pagination = usePagination();
@@ -29,80 +21,12 @@ export default function Events() {
}, },
); );
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]); const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
const columns = useMemo(() => {
return [
columnHelper.accessor((row) => row.createdAt, {
id: "createdAt",
header: () => "Created At",
cell(info) {
return formatDateTime(info.getValue());
},
footer: () => "Created At",
}),
columnHelper.accessor((row) => row.name, {
id: "event",
header: () => "Event",
cell(info) {
return <span className="font-medium">{info.getValue()}</span>;
},
footer: () => "Created At",
}),
columnHelper.accessor((row) => row.profile, {
id: "profile",
header: () => "Profile",
cell(info) {
const profile = info.getValue();
return (
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
{profile?.avatar && <AvatarImage src={profile.avatar} />}
<AvatarFallback className="text-xs">
{profile?.first_name?.at(0)}
</AvatarFallback>
</Avatar>
{`${profile?.first_name} ${profile?.last_name ?? ""}`}
</div>
);
},
footer: () => "Created At",
}),
columnHelper.accessor((row) => row.properties, {
id: "properties",
header: () => "Properties",
cell(info) {
const dots = toDots(info.getValue() as Record<string, any>);
return (
<Table className="mini">
<TableBody>
{Object.keys(dots).map((key) => {
return (
<TableRow key={key}>
<TableCell className="font-medium">{key}</TableCell>
<TableCell>
{typeof dots[key] === "boolean"
? dots[key]
? "true"
: "false"
: dots[key]}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
},
footer: () => "Created At",
}),
];
}, []);
return ( return (
<MainLayout> <MainLayout>
<Container> <Container>
<PageTitle>Events</PageTitle> <PageTitle>Events</PageTitle>
<Pagination {...pagination} /> <EventsTable data={events} pagination={pagination} />
<DataTable data={events} columns={columns}></DataTable>
<Pagination {...pagination} />
</Container> </Container>
</MainLayout> </MainLayout>
); );

View File

@@ -0,0 +1,41 @@
import { Container } from "@/components/Container";
import { PageTitle } from "@/components/PageTitle";
import { usePagination } from "@/components/Pagination";
import { EventsTable } from "@/components/events/EventsTable";
import { MainLayout } from "@/components/layouts/MainLayout";
import { useOrganizationParams } from "@/hooks/useOrganizationParams";
import { useQueryParams } from "@/hooks/useQueryParams";
import { api } from "@/utils/api";
import { useMemo } from "react";
import { z } from "zod";
export default function ProfileId() {
const pagination = usePagination();
const params = useOrganizationParams();
const { profileId } = useQueryParams(
z.object({
profileId: z.string(),
}),
);
const eventsQuery = api.event.list.useQuery(
{
projectSlug: params.project,
profileId,
...pagination,
},
{
keepPreviousData: true,
},
);
const events = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
return (
<MainLayout>
<Container>
<PageTitle>Profile</PageTitle>
<EventsTable data={events} pagination={pagination} />
</Container>
</MainLayout>
);
}

View File

@@ -0,0 +1,100 @@
import { Container } from "@/components/Container";
import { DataTable } from "@/components/DataTable";
import { PageTitle } from "@/components/PageTitle";
import { Pagination, usePagination } from "@/components/Pagination";
import { MainLayout } from "@/components/layouts/MainLayout";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Table, TableBody, TableCell, TableRow } from "@/components/ui/table";
import { useOrganizationParams } from "@/hooks/useOrganizationParams";
import { type RouterOutputs, api } from "@/utils/api";
import { formatDateTime } from "@/utils/date";
import { toDots } from "@/utils/object";
import { AvatarImage } from "@radix-ui/react-avatar";
import { createColumnHelper } from "@tanstack/react-table";
import Link from "next/link";
import { useMemo } from "react";
const columnHelper =
createColumnHelper<RouterOutputs["profile"]["list"][number]>();
export default function Events() {
const pagination = usePagination();
const params = useOrganizationParams();
const eventsQuery = api.profile.list.useQuery(
{
projectSlug: params.project,
...pagination,
},
{
keepPreviousData: true,
},
);
const profiles = useMemo(() => eventsQuery.data ?? [], [eventsQuery]);
const columns = useMemo(() => {
return [
columnHelper.accessor((row) => row.createdAt, {
id: "createdAt",
header: () => "Created At",
cell(info) {
return formatDateTime(info.getValue());
},
}),
columnHelper.accessor('first_name', {
id: "name",
header: () => "Name",
cell(info) {
const profile = info.row.original;
return (
<Link href={`/${params.organization}/${params.project}/profiles/${profile?.id}`} className="flex items-center gap-2">
<Avatar className="h-6 w-6">
{profile?.avatar && <AvatarImage src={profile.avatar} />}
<AvatarFallback className="text-xs">
{profile?.first_name?.at(0)}
</AvatarFallback>
</Avatar>
{`${profile?.first_name} ${profile?.last_name ?? ""}`}
</Link>
);
},
}),
columnHelper.accessor((row) => row.properties, {
id: "properties",
header: () => "Properties",
cell(info) {
const dots = toDots(info.getValue() as Record<string, any>);
if(Object.keys(dots).length === 0) return 'No properties';
return (
<Table className="mini">
<TableBody>
{Object.keys(dots).map((key) => {
return (
<TableRow key={key}>
<TableCell className="font-medium">{key}</TableCell>
<TableCell>
{typeof dots[key] === "boolean"
? dots[key]
? "true"
: "false"
: dots[key]}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
},
}),
];
}, []);
return (
<MainLayout>
<Container>
<PageTitle>Profiles</PageTitle>
<Pagination {...pagination} />
<DataTable data={profiles} columns={columns}></DataTable>
<Pagination {...pagination} />
</Container>
</MainLayout>
);
}

View File

@@ -7,6 +7,7 @@ import { projectRouter } from "./routers/project";
import { clientRouter } from "./routers/client"; import { clientRouter } from "./routers/client";
import { dashboardRouter } from "./routers/dashboard"; import { dashboardRouter } from "./routers/dashboard";
import { eventRouter } from "./routers/event"; import { eventRouter } from "./routers/event";
import { profileRouter } from "./routers/profile";
/** /**
* This is the primary router for your server. * This is the primary router for your server.
@@ -22,6 +23,7 @@ export const appRouter = createTRPCRouter({
project: projectRouter, project: projectRouter,
client: clientRouter, client: clientRouter,
event: eventRouter, event: eventRouter,
profile: profileRouter,
}); });
// export type definition of API // export type definition of API

View File

@@ -190,6 +190,10 @@ function getTotalCount(arr: ResultItem[]) {
return arr.reduce((acc, item) => acc + item.count, 0); return arr.reduce((acc, item) => acc + item.count, 0);
} }
function isFloat(n: number) {
return n % 1 !== 0;
}
function getDatesFromRange(range: IChartRange) { function getDatesFromRange(range: IChartRange) {
if (range === 0) { if (range === 0) {
const startDate = new Date(); const startDate = new Date();
@@ -202,6 +206,16 @@ function getDatesFromRange(range: IChartRange) {
}; };
} }
if (isFloat(range)) {
const startDate = new Date(Date.now() - 1000 * 60 * (range * 100));
const endDate = new Date().toISOString();
return {
startDate: startDate.toISOString(),
endDate: endDate,
};
}
const startDate = getDaysOldDate(range).toISOString(); const startDate = getDaysOldDate(range).toISOString();
const endDate = new Date().toISOString(); const endDate = new Date().toISOString();
return { return {
@@ -210,25 +224,7 @@ function getDatesFromRange(range: IChartRange) {
}; };
} }
async function getChartData({ function getChartSql({ event, chartType, breakdowns, interval, startDate, endDate }: Omit<IGetChartDataInput, 'range'>) {
chartType,
event,
breakdowns,
interval,
range,
startDate: _startDate,
endDate: _endDate,
}: {
event: IChartEvent;
} & Omit<IChartInputWithDates, "events">) {
const { startDate, endDate } =
_startDate && _endDate
? {
startDate: _startDate,
endDate: _endDate,
}
: getDatesFromRange(range);
const select = []; const select = [];
const where = []; const where = [];
const groupBy = []; const groupBy = [];
@@ -338,7 +334,54 @@ async function getChartData({
sql.push(`ORDER BY ${orderBy.join(", ")}`); sql.push(`ORDER BY ${orderBy.join(", ")}`);
} }
const result = await db.$queryRawUnsafe<ResultItem[]>(sql.join("\n")); return sql.join("\n");
}
type IGetChartDataInput = {
event: IChartEvent;
} & Omit<IChartInputWithDates, "events" | 'name'>
async function getChartData({
chartType,
event,
breakdowns,
interval,
range,
startDate: _startDate,
endDate: _endDate,
}: IGetChartDataInput) {
const { startDate, endDate } =
_startDate && _endDate
? {
startDate: _startDate,
endDate: _endDate,
}
: getDatesFromRange(range);
const sql = getChartSql({
chartType,
event,
breakdowns,
interval,
startDate,
endDate,
})
let result = await db.$queryRawUnsafe<ResultItem[]>(sql);
if(result.length === 0 && breakdowns.length > 0) {
result = await db.$queryRawUnsafe<ResultItem[]>(getChartSql({
chartType,
event,
breakdowns: [],
interval,
startDate,
endDate,
}));
}
console.log(sql);
// group by sql label // group by sql label
const series = result.reduce( const series = result.reduce(
@@ -399,7 +442,10 @@ function fillEmptySpotsInTimeline(
const clonedEndDate = new Date(endDate); const clonedEndDate = new Date(endDate);
const today = new Date(); const today = new Date();
if (interval === "hour") { if(interval === 'minute') { 
clonedStartDate.setSeconds(0, 0)
clonedEndDate.setMinutes(clonedEndDate.getMinutes() + 1, 0, 0);
} else if (interval === "hour") {
clonedStartDate.setMinutes(0, 0, 0); clonedStartDate.setMinutes(0, 0, 0);
clonedEndDate.setMinutes(0, 0, 0); clonedEndDate.setMinutes(0, 0, 0);
} else { } else {

View File

@@ -12,13 +12,13 @@ export const eventRouter = createTRPCRouter({
list: protectedProcedure list: protectedProcedure
.input( .input(
z.object({ z.object({
cursor: z.string().optional(),
projectSlug: z.string(), projectSlug: z.string(),
take: z.number().default(100), take: z.number().default(100),
skip: z.number().default(0), skip: z.number().default(0),
profileId: z.string().optional(),
}), }),
) )
.query(async ({ input: { take, skip, projectSlug } }) => { .query(async ({ input: { take, skip, projectSlug, profileId } }) => {
const project = await db.project.findUniqueOrThrow({ const project = await db.project.findUniqueOrThrow({
where: { where: {
slug: projectSlug, slug: projectSlug,
@@ -29,6 +29,7 @@ export const eventRouter = createTRPCRouter({
skip, skip,
where: { where: {
project_id: project.id, project_id: project.id,
profile_id: profileId
}, },
orderBy: { orderBy: {
createdAt: "desc", createdAt: "desc",

View File

@@ -0,0 +1,37 @@
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc";
import { db } from "@/server/db";
export const config = {
api: {
responseLimit: false,
},
};
export const profileRouter = createTRPCRouter({
list: protectedProcedure
.input(
z.object({
projectSlug: z.string(),
take: z.number().default(100),
skip: z.number().default(0),
}),
)
.query(async ({ input: { take, skip, projectSlug } }) => {
const project = await db.project.findUniqueOrThrow({
where: {
slug: projectSlug,
},
});
return db.profile.findMany({
take,
skip,
where: {
project_id: project.id,
},
orderBy: {
createdAt: "desc",
},
});
}),
});

View File

@@ -14,6 +14,7 @@ export const chartTypes = {
}; };
export const intervals = { export const intervals = {
minute: "Minute",
day: "Day", day: "Day",
hour: "Hour", hour: "Hour",
month: "Month", month: "Month",
@@ -33,12 +34,14 @@ export const alphabetIds = [
] as const; ] as const;
export const timeRanges = [ export const timeRanges = [
{ range: 0.3, title: "30m" },
{ range: 0.6, title: "1h" },
{ range: 0, title: "Today" }, { range: 0, title: "Today" },
{ range: 1, title: "24 hours" }, { range: 1, title: "24h" },
{ range: 7, title: "7 days" }, { range: 7, title: "7d" },
{ range: 14, title: "14 days" }, { range: 14, title: "14d" },
{ range: 30, title: "30 days" }, { range: 30, title: "30d" },
{ range: 90, title: "3 months" }, { range: 90, title: "3mo" },
{ range: 180, title: "6 months" }, { range: 180, title: "6mo" },
{ range: 365, title: "1 year" }, { range: 365, title: "1y" },
] as const ] as const;

View File

@@ -39,6 +39,8 @@ export const zChartInput = z.object({
breakdowns: zChartBreakdowns, breakdowns: zChartBreakdowns,
range: z range: z
.literal(0) .literal(0)
.or(z.literal(0.3))
.or(z.literal(0.6))
.or(z.literal(1)) .or(z.literal(1))
.or(z.literal(7)) .or(z.literal(7))
.or(z.literal(14)) .or(z.literal(14))