Skip to content

Commit

Permalink
Start adding timeseries chart
Browse files Browse the repository at this point in the history
  • Loading branch information
TWilson023 committed Oct 15, 2024
1 parent f1168cd commit a76674a
Show file tree
Hide file tree
Showing 9 changed files with 367 additions and 46 deletions.
4 changes: 2 additions & 2 deletions apps/web/app/api/workspaces/[idOrSlug]/billing/usage/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { NextResponse } from "next/server";
export const GET = withWorkspace(async ({ searchParams, workspace }) => {
const { resource } = usageQuerySchema.parse(searchParams);
const { billingCycleStart } = workspace;
const { firstDay } = getFirstAndLastDay(billingCycleStart);
const { firstDay, lastDay } = getFirstAndLastDay(billingCycleStart);

const pipe = tb.buildPipe({
pipe: `v1_usage`,
Expand All @@ -33,7 +33,7 @@ export const GET = withWorkspace(async ({ searchParams, workspace }) => {
resource,
workspaceId: workspace.id,
start: firstDay.toISOString().replace("T", " ").replace("Z", ""),
end: new Date().toISOString().replace("T", " ").replace("Z", ""),
end: lastDay.toISOString().replace("T", " ").replace("Z", ""),
});

return NextResponse.json(response.data);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,13 @@ import {
import { CircleDollar, CursorRays, Hyperlink } from "@dub/ui/src/icons";
import { cn, getFirstAndLastDay, nFormatter } from "@dub/utils";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useRouter } from "next/navigation";
import { CSSProperties, useMemo, useState } from "react";
import { toast } from "sonner";
import { UsageChart } from "./usage-chart";

export default function WorkspaceBillingClient() {
const router = useRouter();
const searchParams = useSearchParams();

const {
id: workspaceId,
Expand Down Expand Up @@ -153,10 +153,8 @@ export default function WorkspaceBillingClient() {
/>
)}
</div>
<div className="px-8 pb-8">
<div className="flex h-72 w-full items-center justify-center rounded-md bg-neutral-200 text-neutral-500">
WIP
</div>
<div className="w-full px-8 pb-8">
<UsageChart />
</div>
</div>
<div className="grid grid-cols-1 divide-y divide-gray-200 sm:divide-x sm:divide-y-0 md:grid-cols-3">
Expand Down Expand Up @@ -265,7 +263,7 @@ function UsageTabCard({
return (
<button
className={cn(
"rounded-md border border-neutral-300 bg-white px-5 py-4 text-left",
"rounded-lg border border-neutral-300 bg-white px-5 py-4 text-left transition-colors duration-75 hover:bg-neutral-50",
isActive && "border-neutral-900 ring-1 ring-neutral-900",
)}
aria-selected={isActive}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import useUsage from "@/lib/swr/use-usage";
import { Bars } from "@/ui/charts/bars";
import TimeSeriesChart from "@/ui/charts/time-series-chart";
import XAxis from "@/ui/charts/x-axis";
import { useSearchParams } from "next/navigation";
import { useMemo } from "react";

const RESOURCES = ["links", "events", "revenue"] as const;

export function UsageChart() {
const searchParams = useSearchParams();
const resource =
RESOURCES.find((r) => r === searchParams.get("tab")) ?? "links";

const { usage } = useUsage({ resource: resource as any });

const chartData = useMemo(
() =>
usage?.map(({ date, value }) => ({
date: new Date(date),
values: { usage: value },
})),
[usage],
);

return (
<div className="h-64">
{chartData && chartData.length > 0 ? (
<TimeSeriesChart
type="bar"
data={chartData}
series={[
{
id: "usage",
valueAccessor: (d) => d.values.usage,
colorClassName: "text-violet-500",
isActive: true,
},
]}
>
<Bars />
<XAxis />
</TimeSeriesChart>
) : null}
</div>
);
}
4 changes: 4 additions & 0 deletions apps/web/ui/charts/areas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export default function Areas({
}) {
const { data, series, margin, xScale, yScale, startDate, endDate } =
useChartContext();

if (!("ticks" in xScale))
throw new Error("Areas require a time scale (type=area)");

const { tooltipData } = useChartTooltipContext();

// Data with all values set to zero to animate from
Expand Down
86 changes: 86 additions & 0 deletions apps/web/ui/charts/bars.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { cn } from "@dub/utils";
import { LinearGradient } from "@visx/gradient";
import { Group } from "@visx/group";
import { BarRounded } from "@visx/shape";
import { AnimatePresence, motion } from "framer-motion";
import { useMemo } from "react";
import { useChartContext } from "./chart-context";

export function Bars({
seriesClassNames,
}: {
seriesClassNames?: { id: string; gradient?: string; bar?: string }[];
}) {
const { data, series, margin, xScale, yScale, height, startDate, endDate } =
useChartContext();

if (!("bandwidth" in xScale))
throw new Error("Bars require a band scale (type=bar)");

// Data with all values set to zero to animate from
const zeroedData = useMemo(() => {
return data.map((d) => ({
...d,
values: Object.fromEntries(Object.keys(d.values).map((key) => [key, 0])),
})) as typeof data;
}, [data]);

return (
<Group left={margin.left} top={margin.top}>
<AnimatePresence>
{series
.filter(({ isActive }) => isActive)
.map((s) => (
// Prevent ugly x-scale animations when start/end dates change with unique key
<motion.g
initial={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.1 }}
key={`${s.id}_${startDate.toString()}_${endDate.toString()}`}
>
{/* Bar gradient */}
<LinearGradient
className={cn(
s.colorClassName ?? "text-blue-500",
seriesClassNames?.find(({ id }) => id === s.id)?.gradient,
)}
id={`${s.id}-background`}
fromOffset="0%"
from="currentColor"
fromOpacity={0.01}
toOffset="40%"
to="currentColor"
toOpacity={1}
x1={0}
x2={0}
y1={1}
/>

{/* Bars */}
{data.map((d) => {
const barWidth = xScale.bandwidth();
const y = yScale(s.valueAccessor(d) ?? 0);
const barHeight = height - y;
return (
<BarRounded
key={d.date.toString()}
x={xScale(d.date) ?? 0}
y={y}
width={barWidth}
height={barHeight}
radius={1000}
top
className={cn(
s.colorClassName ?? "text-blue-700",
seriesClassNames?.find(({ id }) => id === s.id)?.bar,
)}
fill={`url(#${s.id}-background)`}
/>
);
})}
</motion.g>
))}
</AnimatePresence>
</Group>
);
}
94 changes: 59 additions & 35 deletions apps/web/ui/charts/time-series-chart.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { cn } from "@dub/utils";
import { Group } from "@visx/group";
import { ParentSize } from "@visx/responsive";
import { scaleLinear, scaleUtc } from "@visx/scale";
import { scaleBand, scaleLinear, scaleUtc } from "@visx/scale";
import { Bar, Circle, Line } from "@visx/shape";
import { PropsWithChildren, useMemo, useState } from "react";
import { ChartContext, ChartTooltipContext } from "./chart-context";
Expand Down Expand Up @@ -32,6 +32,7 @@ export default function TimeSeriesChart<T extends Datum>(
}

function TimeSeriesChartInner<T extends Datum>({
type = "area",
width: outerWidth,
height: outerHeight,
children,
Expand All @@ -45,10 +46,7 @@ function TimeSeriesChartInner<T extends Datum>({
bottom: 32,
left: 4,
},
padding = {
top: 0.1,
bottom: 0.1,
},
padding: paddingProp,
}: {
width: number;
height: number;
Expand All @@ -60,6 +58,11 @@ function TimeSeriesChartInner<T extends Datum>({
left: marginProp.left + (leftAxisMargin ?? 0),
};

const padding = paddingProp ?? {
top: 0.1,
bottom: type === "area" ? 0.1 : 0,
};

const width = outerWidth - margin.left - margin.right;
const height = outerHeight - margin.top - margin.bottom;

Expand Down Expand Up @@ -98,14 +101,23 @@ function TimeSeriesChartInner<T extends Datum>({
nice: true,
clamp: true,
}),
xScale: scaleUtc<number>({
domain: [startDate, endDate],
range: [0, width],
}),
xScale:
type === "area"
? scaleUtc<number>({
domain: [startDate, endDate],
range: [0, width],
})
: scaleBand({
domain: data.map(({ date }) => date),
range: [0, width],
padding: Math.min(0.75, (width / data.length) * 0.02),
align: 0.5,
}),
};
}, [startDate, endDate, minY, maxY, height, width]);
}, [startDate, endDate, minY, maxY, height, width, type]);

const chartContext: ChartContextType<T> = {
type,
width,
height,
data,
Expand Down Expand Up @@ -146,31 +158,43 @@ function TimeSeriesChartInner<T extends Datum>({
{children}
<Group left={margin.left} top={margin.top}>
{/* Tooltip hover line + circle */}
{tooltipData && (
<>
<Line
x1={xScale(tooltipData.date)}
x2={xScale(tooltipData.date)}
y1={height}
y2={0}
stroke="black"
strokeWidth={1.5}
/>

{series
.filter(({ isActive }) => isActive)
.map((s) => (
<Circle
key={s.id}
cx={xScale(tooltipData.date)}
cy={yScale(s.valueAccessor(tooltipData))}
r={4}
className={s.colorClassName ?? "text-blue-800"}
fill="currentColor"
/>
))}
</>
)}
{tooltipData &&
("bandwidth" in xScale ? (
<>
<Bar
x={xScale(tooltipData.date) ?? 0}
width={xScale.bandwidth()}
y={0}
height={height}
fill="black"
fillOpacity={0.05}
/>
</>
) : (
<>
<Line
x1={xScale(tooltipData.date)}
x2={xScale(tooltipData.date)}
y1={height}
y2={0}
stroke="black"
strokeWidth={1.5}
/>

{series
.filter(({ isActive }) => isActive)
.map((s) => (
<Circle
key={s.id}
cx={xScale(tooltipData.date)}
cy={yScale(s.valueAccessor(tooltipData))}
r={4}
className={s.colorClassName ?? "text-blue-800"}
fill="currentColor"
/>
))}
</>
))}

{/* Tooltip hover region */}
<Bar
Expand Down
5 changes: 4 additions & 1 deletion apps/web/ui/charts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type ChartRequiredProps<T extends Datum = any> = {
};

type ChartOptionalProps<T extends Datum = any> = {
type?: "area" | "bar";
tooltipContent?: (datum: TimeSeriesDatum<T>) => ReactElement | string;
tooltipClassName?: string;

Expand Down Expand Up @@ -61,7 +62,9 @@ export type ChartContext<T extends Datum = any> = Required<ChartProps<T>> & {
height: number;
startDate: Date;
endDate: Date;
xScale: ScaleTypeToD3Scale<number>["utc"];
xScale:
| ScaleTypeToD3Scale<number>["utc"]
| ScaleTypeToD3Scale<number>["band"];
yScale: ScaleTypeToD3Scale<number>["linear"];
minY: number;
maxY: number;
Expand Down
12 changes: 11 additions & 1 deletion apps/web/ui/charts/useTooltip.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,17 @@ export function useTooltip<T extends Datum>({
) => {
const lp = localPoint(event) || { x: 0 };
const x = lp.x - margin.left;
const x0 = xScale.invert(x);
const x0 =
"invert" in xScale
? xScale.invert(x)
: (xScale.domain()[
Math.round((x - xScale.step() * 0.75) / xScale.step())
] as Date | undefined);

if (x0 === undefined) {
visxTooltip.hideTooltip();
return;
}
const index = bisectDate(data, x0, 1);
const d0 = data[index - 1];
const d1 = data[index];
Expand Down
Loading

0 comments on commit a76674a

Please sign in to comment.