gui: work in progress

This commit is contained in:
Carl-Gerhard Lindesvärd
2023-10-17 21:47:37 +02:00
parent b9fe6127ff
commit 206ae54dea
53 changed files with 2632 additions and 88 deletions

View File

@@ -0,0 +1,90 @@
import { api } from "@/utils/api";
import {
CartesianGrid,
Legend,
Line,
LineChart,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { ReportLineChartTooltip } from "./ReportLineChartTooltop";
import { useFormatDateInterval } from "@/hooks/useFormatDateInterval";
import {
type IChartBreakdown,
type IChartEvent,
type IInterval,
} from "@/types";
import { getChartColor } from "@/utils/theme";
import { ReportTable } from "./ReportTable";
export function ReportLineChart({
interval,
startDate,
endDate,
events,
breakdowns,
}: {
interval: IInterval;
startDate: Date;
endDate: Date;
events: IChartEvent[];
breakdowns: IChartBreakdown[];
}) {
const chart = api.chartMeta.chart.useQuery(
{
interval,
chartType: "linear",
startDate,
endDate,
events,
breakdowns,
},
{
enabled: events.length > 0,
},
);
const formatDate = useFormatDateInterval(interval);
return (
<>
{chart.isSuccess && chart.data?.series?.[0]?.data && (
<>
<LineChart width={800} height={400}>
<Legend />
<YAxis dataKey={"count"}></YAxis>
<Tooltip content={<ReportLineChartTooltip />} />
{/* <Tooltip /> */}
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
tickFormatter={(m: Date) => {
return formatDate(m);
}}
tickLine={false}
allowDuplicatedCategory={false}
/>
{chart.data?.series.slice(0, 5).map((serie, index) => {
const key = serie.name;
const strokeColor = getChartColor(index)
return (
<Line
type="monotone"
key={key}
isAnimationActive={false}
strokeWidth={2}
dataKey="count"
stroke={strokeColor}
data={serie.data}
name={serie.name}
/>
);
})}
</LineChart>
<ReportTable data={chart.data} />
</>
)}
</>
);
}

View File

@@ -0,0 +1,54 @@
import { type IToolTipProps } from "@/types";
type ReportLineChartTooltipProps = IToolTipProps<{
color: string;
value: number;
payload: {
date: Date;
count: number;
label: string;
};
}>
export function ReportLineChartTooltip({
active,
payload,
}: ReportLineChartTooltipProps) {
if (!active || !payload) {
return null;
}
if (!payload.length) {
return null;
}
const limit = 3;
const sorted = payload.slice(0).sort((a, b) => b.value - a.value);
const visible = sorted.slice(0, limit);
const hidden = sorted.slice(limit);
return (
<div className="flex flex-col gap-2 rounded-xl border bg-white p-3 text-sm shadow-xl">
{visible.map((item) => {
return (
<div key={item.payload.label} className="flex gap-2">
<div
className="w-[3px] rounded-full"
style={{ background: item.color }}
></div>
<div className="flex flex-col">
<div className="min-w-0 max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap font-medium">
{item.payload.label}
</div>
<div>{item.payload.count}</div>
</div>
</div>
);
})}
{hidden.length > 0 && (
<div className="text-muted-foreground">and {hidden.length} more...</div>
)}
</div>
);
}

View File

@@ -0,0 +1,72 @@
import * as React from "react";
import { type RouterOutputs } from "@/utils/api";
import { useFormatDateInterval } from "@/hooks/useFormatDateInterval";
import { useSelector } from "@/redux";
import { Checkbox } from "@/components/ui/checkbox";
import { getChartColor } from "@/utils/theme";
export function ReportTable({
data,
}: {
data: RouterOutputs["chartMeta"]["chart"];
}) {
const interval = useSelector((state) => state.report.interval);
const formatDate = useFormatDateInterval(interval);
return (
<div className="flex min-w-0">
{/* Labels */}
<div>
<div className="font-medium">Name</div>
{data.series.map((serie, index) => {
const checked = index < 5;
return (
<div
key={serie.name}
className="max-w-[200px] overflow-hidden text-ellipsis whitespace-nowrap flex items-center gap-2"
>
<Checkbox
style={checked ? {
background: getChartColor(index),
borderColor: getChartColor(index),
} : undefined}
checked={checked}
/>
{serie.name}
</div>
);
})}
</div>
{/* ScrollView for all values */}
<div className="min-w-0 overflow-auto">
{/* Header */}
<div className="flex">
{data.series[0]?.data.map((serie, index) => (
<div
key={serie.date.toString()}
className="min-w-[80px] text-right font-medium"
>
{formatDate(serie.date)}
</div>
))}
</div>
{/* Values */}
{data.series.map((serie, index) => {
return (
<div className="flex" key={serie.name}>
{serie.data.map((item) => {
return (
<div key={item.date} className="min-w-[80px] text-right">
{item.count}
</div>
);
})}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import {
type IChartBreakdown,
type IChartEvent,
type IInterval,
} from "@/types";
import { type PayloadAction, createSlice } from "@reduxjs/toolkit";
type InitialState = {
events: IChartEvent[];
breakdowns: IChartBreakdown[];
interval: IInterval;
startDate: Date;
endDate: Date;
};
// First approach: define the initial state using that type
const initialState: InitialState = {
startDate: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7),
endDate: new Date(),
interval: "day",
breakdowns: [
{
id: "A",
name: "properties.params.title",
},
],
events: [
{
id: "A",
displayName: "screen_view (0)",
name: "screen_view",
filters: [
{
id: "1",
name: "properties.route",
value: "RecipeDetails",
},
],
},
],
};
const IDS = ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J"] as const;
export const reportSlice = createSlice({
name: "counter",
initialState,
reducers: {
// Events
addEvent: (state, action: PayloadAction<Omit<IChartEvent, "id">>) => {
state.events.push({
id: IDS[state.events.length]!,
...action.payload,
});
},
removeEvent: (
state,
action: PayloadAction<{
id: string;
}>,
) => {
state.events = state.events.filter(
(event) => event.id !== action.payload.id,
);
},
changeEvent: (state, action: PayloadAction<IChartEvent>) => {
state.events = state.events.map((event) => {
if (event.id === action.payload.id) {
return action.payload;
}
return event;
});
},
// Breakdowns
addBreakdown: (
state,
action: PayloadAction<Omit<IChartBreakdown, "id">>,
) => {
state.breakdowns.push({
id: IDS[state.breakdowns.length]!,
...action.payload,
});
},
removeBreakdown: (
state,
action: PayloadAction<{
id: string;
}>,
) => {
state.breakdowns = state.breakdowns.filter(
(event) => event.id !== action.payload.id,
);
},
changeBreakdown: (state, action: PayloadAction<IChartBreakdown>) => {
state.breakdowns = state.breakdowns.map((breakdown) => {
if (breakdown.id === action.payload.id) {
return action.payload;
}
return breakdown;
});
},
// Interval
changeInterval: (state, action: PayloadAction<IInterval>) => {
state.interval = action.payload;
},
// Date range
changeStartDate: (state, action: PayloadAction<Date>) => {
state.startDate = action.payload;
},
// Date range
changeEndDate: (state, action: PayloadAction<Date>) => {
state.endDate = action.payload;
},
},
});
// Action creators are generated for each case reducer function
export const {
addEvent,
removeEvent,
changeEvent,
addBreakdown,
removeBreakdown,
changeBreakdown,
changeInterval,
} = reportSlice.actions;
export default reportSlice.reducer;

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import { Filter, MoreHorizontal, Tags, Trash } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export type ReportBreakdownMoreProps = {
onClick: (action: 'remove') => void
}
export function ReportBreakdownMore({ onClick }: ReportBreakdownMoreProps) {
const [open, setOpen] = React.useState(false)
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem className="text-red-600" onClick={() => onClick('remove')}>
<Trash className="mr-2 h-4 w-4" />
Delete
<DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,77 @@
import { api } from "@/utils/api";
import { Combobox } from "@/components/ui/combobox";
import { useDispatch, useSelector } from "@/redux";
import { addBreakdown, changeBreakdown, removeBreakdown } from "../reportSlice";
import { type ReportEventMoreProps } from "./ReportEventMore";
import { type IChartBreakdown } from "@/types";
import { ReportBreakdownMore } from "./ReportBreakdownMore";
export function ReportBreakdowns() {
const selectedBreakdowns = useSelector((state) => state.report.breakdowns);
const dispatch = useDispatch();
const propertiesQuery = api.chartMeta.properties.useQuery();
const propertiesCombobox = (propertiesQuery.data ?? []).map((item) => ({
value: item,
label: item,
}));
const handleMore = (breakdown: IChartBreakdown) => {
const callback: ReportEventMoreProps["onClick"] = (action) => {
switch (action) {
case "remove": {
return dispatch(removeBreakdown(breakdown));
}
}
};
return callback;
};
return (
<div>
<h3 className="mb-2 font-medium">Breakdown</h3>
<div className="flex flex-col gap-4">
{selectedBreakdowns.map((item, index) => {
return (
<div key={item.name} className="rounded-lg border">
<div className="flex items-center gap-2 p-2 px-4">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-purple-500 text-xs font-medium text-white">
{index}
</div>
<Combobox
value={item.name}
onChange={(value) => {
dispatch(
changeBreakdown({
...item,
name: value,
}),
);
}}
items={propertiesCombobox}
placeholder="Select..."
/>
<ReportBreakdownMore onClick={handleMore(item)} />
</div>
</div>
);
})}
{selectedBreakdowns.length === 0 && (
<Combobox
value={""}
onChange={(value) => {
dispatch(
addBreakdown({
name: value,
}),
);
}}
items={propertiesCombobox}
placeholder="Select breakdown"
/>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,181 @@
import { api } from "@/utils/api";
import { type IChartEvent } from "@/types";
import {
CreditCard,
SlidersHorizontal,
Trash,
} from "lucide-react";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { type Dispatch } from "react";
import { RenderDots } from "@/components/ui/RenderDots";
import { useDispatch } from "@/redux";
import { changeEvent } from "../reportSlice";
import { Combobox } from "@/components/ui/combobox";
import { Button } from "@/components/ui/button";
type ReportEventFiltersProps = {
event: IChartEvent;
isCreating: boolean;
setIsCreating: Dispatch<boolean>;
};
export function ReportEventFilters({
event,
isCreating,
setIsCreating,
}: ReportEventFiltersProps) {
const dispatch = useDispatch();
const propertiesQuery = api.chartMeta.properties.useQuery(
{
event: event.name,
},
{
enabled: !!event.name,
},
);
return (
<div>
<div className="flex flex-col divide-y bg-slate-50">
{event.filters.map((filter) => {
return <Filter key={filter.name} filter={filter} event={event} />;
})}
<CommandDialog open={isCreating} onOpenChange={setIsCreating} modal>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>Such emptyness 🤨</CommandEmpty>
<CommandGroup heading="Properties">
{propertiesQuery.data?.map((item) => (
<CommandItem
key={item}
onSelect={() => {
setIsCreating(false);
dispatch(
changeEvent({
...event,
filters: [
...event.filters,
{
id: (event.filters.length + 1).toString(),
name: item,
value: "",
},
],
}),
);
}}
>
<CreditCard className="mr-2 h-4 w-4" />
<RenderDots className="text-sm">{item}</RenderDots>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</CommandList>
</CommandDialog>
</div>
</div>
);
}
type FilterProps = {
event: IChartEvent;
filter: IChartEvent["filters"][number];
};
function Filter({ filter, event }: FilterProps) {
const dispatch = useDispatch();
const potentialValues = api.chartMeta.values.useQuery({
event: event.name,
property: filter.name,
});
const valuesCombobox =
potentialValues.data?.values?.map((item) => ({
value: item,
label: item,
})) ?? [];
const removeFilter = () => {
dispatch(
changeEvent({
...event,
filters: event.filters.filter((item) => item.id !== filter.id),
}),
);
};
const changeFilter = (value: string) => {
dispatch(
changeEvent({
...event,
filters: event.filters.map((item) => {
if (item.id === filter.id) {
return {
...item,
value,
};
}
return item;
}),
}),
);
};
return (
<div
key={filter.name}
className="px-4 py-2 shadow-[inset_6px_0_0] shadow-slate-200 first:border-t"
>
<div className="mb-2 flex items-center gap-2">
<div className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded bg-emerald-600 text-xs font-medium text-white">
<SlidersHorizontal size={10} />
</div>
<RenderDots className="text-sm">{filter.name}</RenderDots>
<Button variant="ghost" size="sm" onClick={removeFilter}>
<Trash size={16} />
</Button>
</div>
{/* <ComboboxMulti items={valuesCombobox} selected={[]} setSelected={(fn) => {
return fn(filter.value)
//
}} /> */}
<Combobox
items={valuesCombobox}
value={filter.value}
placeholder="Select value"
onChange={changeFilter}
/>
{/* <Input
value={filter.value}
onChange={(e) => {
dispatch(
changeEvent({
...event,
filters: event.filters.map((item) => {
if (item.id === filter.id) {
return {
...item,
value: e.currentTarget.value,
};
}
return item;
}),
}),
);
}}
/> */}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import * as React from "react"
import { Filter, MoreHorizontal, Tags, Trash } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const labels = [
"feature",
"bug",
"enhancement",
"documentation",
"design",
"question",
"maintenance",
]
export type ReportEventMoreProps = {
onClick: (action: 'createFilter' | 'remove') => void
}
export function ReportEventMore({ onClick }: ReportEventMoreProps) {
const [open, setOpen] = React.useState(false)
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onClick('createFilter')}>
<Filter className="mr-2 h-4 w-4" />
Add filter
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600" onClick={() => onClick('remove')}>
<Trash className="mr-2 h-4 w-4" />
Delete
<DropdownMenuShortcut></DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,85 @@
import { api } from "@/utils/api";
import { Combobox } from "@/components/ui/combobox";
import { useDispatch, useSelector } from "@/redux";
import { addEvent, changeEvent, removeEvent } from "../reportSlice";
import { ReportEventFilters } from "./ReportEventFilters";
import { useState } from "react";
import { ReportEventMore, type ReportEventMoreProps } from "./ReportEventMore";
import { type IChartEvent } from "@/types";
export function ReportEvents() {
const [isCreating, setIsCreating] = useState(false);
const selectedEvents = useSelector((state) => state.report.events);
const dispatch = useDispatch();
const eventsQuery = api.chartMeta.events.useQuery();
const eventsCombobox = (eventsQuery.data ?? []).map((item) => ({
value: item.name,
label: item.name,
}));
const handleMore = (event: IChartEvent) => {
const callback: ReportEventMoreProps["onClick"] = (action) => {
switch (action) {
case "createFilter": {
return setIsCreating(true);
}
case "remove": {
return dispatch(removeEvent(event));
}
}
};
return callback;
};
return (
<div>
<h3 className="mb-2 font-medium">Events</h3>
<div className="flex flex-col gap-4">
{selectedEvents.map((event, index) => {
return (
<div key={event.name} className="border rounded-lg">
<div className="flex gap-2 items-center p-2 px-4">
<div className="flex-shrink-0 bg-purple-500 w-5 h-5 rounded text-xs flex items-center justify-center text-white font-medium">{index}</div>
<Combobox
value={event.name}
onChange={(value) => {
dispatch(
changeEvent({
...event,
name: value,
filters: [],
}),
);
}}
items={eventsCombobox}
placeholder="Select event"
/>
<ReportEventMore onClick={handleMore(event)} />
</div>
<ReportEventFilters
{...{ isCreating, setIsCreating, event }}
/>
</div>
);
})}
<Combobox
value={""}
onChange={(value) => {
dispatch(
addEvent({
displayName: `${value} (${selectedEvents.length})`,
name: value,
filters: [],
}),
);
}}
items={eventsCombobox}
placeholder="Select event"
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
import { ReportEvents } from "./ReportEvents";
import { ReportBreakdowns } from "./ReportBreakdowns";
export function ReportSidebar() {
return (
<div className="flex flex-col gap-4 p-4">
<ReportEvents />
<ReportBreakdowns />
</div>
);
}