From fc298c238c51172cb164d5d8cd4710e0285f2905 Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Thu, 3 Oct 2024 16:09:54 -0700 Subject: [PATCH 01/12] FEAT: timeseries chart for usage stats --- apps/web/app/api/ai/completion/route.ts | 2 - .../[idOrSlug]/billing/usage/route.ts | 40 +++++++++++++++++++ apps/web/lib/swr/use-usage.ts | 26 ++++++++++++ apps/web/lib/types.ts | 3 ++ apps/web/lib/zod/schemas/usage.ts | 10 +++++ 5 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/api/workspaces/[idOrSlug]/billing/usage/route.ts create mode 100644 apps/web/lib/swr/use-usage.ts create mode 100644 apps/web/lib/zod/schemas/usage.ts diff --git a/apps/web/app/api/ai/completion/route.ts b/apps/web/app/api/ai/completion/route.ts index 761eabbbcd..9d8433bb71 100644 --- a/apps/web/app/api/ai/completion/route.ts +++ b/apps/web/app/api/ai/completion/route.ts @@ -26,8 +26,6 @@ export const POST = withWorkspaceEdge( model, } = completionSchema.parse(await req.json()); - console.log({ prompt }); - const result = await streamText({ model: anthropic(model), messages: [ diff --git a/apps/web/app/api/workspaces/[idOrSlug]/billing/usage/route.ts b/apps/web/app/api/workspaces/[idOrSlug]/billing/usage/route.ts new file mode 100644 index 0000000000..487baa1212 --- /dev/null +++ b/apps/web/app/api/workspaces/[idOrSlug]/billing/usage/route.ts @@ -0,0 +1,40 @@ +import { withWorkspace } from "@/lib/auth"; +import { tb } from "@/lib/tinybird"; +import z from "@/lib/zod"; +import { usageQuerySchema, usageResponse } from "@/lib/zod/schemas/usage"; +import { getFirstAndLastDay } from "@dub/utils"; +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 pipe = tb.buildPipe({ + pipe: `v1_usage`, + parameters: usageQuerySchema.extend({ + workspaceId: z + .string() + .optional() + .transform((v) => { + if (v && !v.startsWith("ws_")) { + return `ws_${v}`; + } else { + return v; + } + }), + start: z.string(), + end: z.string(), + }), + data: usageResponse, + }); + + const response = await pipe({ + resource, + workspaceId: workspace.id, + start: firstDay.toISOString().replace("T", " ").replace("Z", ""), + end: new Date().toISOString().replace("T", " ").replace("Z", ""), + }); + + return NextResponse.json(response.data); +}); diff --git a/apps/web/lib/swr/use-usage.ts b/apps/web/lib/swr/use-usage.ts new file mode 100644 index 0000000000..d947686b0e --- /dev/null +++ b/apps/web/lib/swr/use-usage.ts @@ -0,0 +1,26 @@ +import { fetcher } from "@dub/utils"; +import useSWR from "swr"; +import { UsageResponse } from "../types"; +import useWorkspace from "./use-workspace"; + +export default function useUsage({ + resource, +}: { + resource: "links" | "events"; +}) { + const { id } = useWorkspace(); + + const { data: usage, isValidating } = useSWR( + id && `/api/workspaces/${id}/billing/usage?resource=${resource}`, + fetcher, + { + dedupingInterval: 60000, + }, + ); + + return { + usage, + loading: usage ? false : true, + isValidating, + }; +} diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 75c5dc638a..46433f5532 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -10,6 +10,7 @@ import { createLinkBodySchema } from "./zod/schemas/links"; import { createOAuthAppSchema, oAuthAppSchema } from "./zod/schemas/oauth"; import { trackSaleResponseSchema } from "./zod/schemas/sales"; import { tokenSchema } from "./zod/schemas/token"; +import { usageResponse } from "./zod/schemas/usage"; import { createWebhookSchema, webhookEventSchemaTB, @@ -259,3 +260,5 @@ export type TrackCustomerResponse = z.infer; export type TrackLeadResponse = z.infer; export type TrackSaleResponse = z.infer; + +export type UsageResponse = z.infer; diff --git a/apps/web/lib/zod/schemas/usage.ts b/apps/web/lib/zod/schemas/usage.ts new file mode 100644 index 0000000000..382a93a099 --- /dev/null +++ b/apps/web/lib/zod/schemas/usage.ts @@ -0,0 +1,10 @@ +import z from "@/lib/zod"; + +export const usageQuerySchema = z.object({ + resource: z.enum(["links", "events"]), +}); + +export const usageResponse = z.object({ + date: z.string(), + value: z.number(), +}); From f1168cd305e64a84fa3170110723b09dfa0f7b93 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Mon, 14 Oct 2024 17:43:58 -0400 Subject: [PATCH 02/12] Start updating billing/usage page --- .../[slug]/settings/billing/page-client.tsx | 208 +++++++++++++----- .../ui/src/icons/nucleo/circle-dollar.tsx | 56 +++++ packages/ui/src/icons/nucleo/index.ts | 1 + 3 files changed, 213 insertions(+), 52 deletions(-) create mode 100644 packages/ui/src/icons/nucleo/circle-dollar.tsx diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx index 6f4ed8eb4e..59e67d2fae 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx @@ -3,14 +3,21 @@ import useTags from "@/lib/swr/use-tags"; import useUsers from "@/lib/swr/use-users"; import useWorkspace from "@/lib/swr/use-workspace"; -import { Divider } from "@/ui/shared/icons"; import Infinity from "@/ui/shared/icons/infinity"; import PlanBadge from "@/ui/workspaces/plan-badge"; -import { Button, buttonVariants, InfoTooltip, ProgressBar } from "@dub/ui"; +import { + Button, + buttonVariants, + Icon, + InfoTooltip, + ProgressBar, + useRouterStuff, +} from "@dub/ui"; +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 { useMemo, useState } from "react"; +import { CSSProperties, useMemo, useState } from "react"; import { toast } from "sonner"; export default function WorkspaceBillingClient() { @@ -60,10 +67,10 @@ export default function WorkspaceBillingClient() { return (
-
-
-

Plan & Usage

-

+

+
+

Plan and Usage

+

You are currently on the{" "} {plan ? ( @@ -113,43 +120,46 @@ export default function WorkspaceBillingClient() { )}

-
- {conversionEnabled && ( - +
+ - )} - = 1000000000) || false} - /> + + {conversionEnabled && ( + + )} +
+
+
+ WIP +
+
-
- = 1000000000) || false} - /> +
-
-
-
+
{plan ? (

{plan === "enterprise" @@ -220,6 +228,101 @@ export default function WorkspaceBillingClient() { ); } +function UsageTabCard({ + id, + icon: Icon, + title, + usage: usageProp, + limit: limitProp, + unit, + root, +}: { + id: string; + icon: Icon; + title: string; + usage?: number; + limit?: number; + unit?: string; + root?: boolean; +}) { + const { searchParams, queryParams } = useRouterStuff(); + + const isActive = + searchParams.get("tab") === id || (!searchParams.get("tab") && root); + + const [usage, limit] = + unit === "$" && usageProp !== undefined && limitProp !== undefined + ? [usageProp / 100, limitProp / 100] + : [usageProp, limitProp]; + + const loading = usage === undefined || limit === undefined; + const unlimited = limit !== undefined && limit >= 1000000000; + const warning = !loading && !unlimited && usage >= limit * 0.9; + const remaining = !loading && !unlimited ? Math.max(0, limit - usage) : 0; + + const prefix = unit || ""; + + return ( + + ); +} + function UsageCategory(data: { title: string; unit: string; @@ -234,26 +337,27 @@ function UsageCategory(data: { usage = usage / 100; usageLimit = usageLimit / 100; } + return ( -

+
-

{title}

+

{title}

{numberOnly ? ( -
+
{usage || usage === 0 ? ( -

+

{nFormatter(usage, { full: true })}

) : ( -
+
)} - + / {usageLimit && usageLimit >= 1000000000 ? ( ) : ( -

+

{nFormatter(usageLimit, { full: true })}

)} diff --git a/packages/ui/src/icons/nucleo/circle-dollar.tsx b/packages/ui/src/icons/nucleo/circle-dollar.tsx new file mode 100644 index 0000000000..2ff132cbfa --- /dev/null +++ b/packages/ui/src/icons/nucleo/circle-dollar.tsx @@ -0,0 +1,56 @@ +import { SVGProps } from "react"; + +export function CircleDollar(props: SVGProps) { + return ( + + + + + + + + + ); +} diff --git a/packages/ui/src/icons/nucleo/index.ts b/packages/ui/src/icons/nucleo/index.ts index 67c87f3d80..f48b7014e7 100644 --- a/packages/ui/src/icons/nucleo/index.ts +++ b/packages/ui/src/icons/nucleo/index.ts @@ -18,6 +18,7 @@ export * from "./check2"; export * from "./checkbox-checked-fill"; export * from "./checkbox-unchecked"; export * from "./circle-check"; +export * from "./circle-dollar"; export * from "./circle-dotted"; export * from "./circle-half-dotted-clock"; export * from "./circle-info"; From a76674a317b7d421085b9e6da849004eceedf797 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Tue, 15 Oct 2024 14:01:33 -0400 Subject: [PATCH 03/12] Start adding timeseries chart --- .../[idOrSlug]/billing/usage/route.ts | 4 +- .../[slug]/settings/billing/page-client.tsx | 12 +- .../[slug]/settings/billing/usage-chart.tsx | 47 ++++++ apps/web/ui/charts/areas.tsx | 4 + apps/web/ui/charts/bars.tsx | 86 ++++++++++ apps/web/ui/charts/time-series-chart.tsx | 94 +++++++---- apps/web/ui/charts/types.ts | 5 +- apps/web/ui/charts/useTooltip.ts | 12 +- packages/tinybird/pipes/v1_usage.pipe | 149 ++++++++++++++++++ 9 files changed, 367 insertions(+), 46 deletions(-) create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/usage-chart.tsx create mode 100644 apps/web/ui/charts/bars.tsx create mode 100644 packages/tinybird/pipes/v1_usage.pipe diff --git a/apps/web/app/api/workspaces/[idOrSlug]/billing/usage/route.ts b/apps/web/app/api/workspaces/[idOrSlug]/billing/usage/route.ts index 487baa1212..09400dc64d 100644 --- a/apps/web/app/api/workspaces/[idOrSlug]/billing/usage/route.ts +++ b/apps/web/app/api/workspaces/[idOrSlug]/billing/usage/route.ts @@ -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`, @@ -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); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx index 59e67d2fae..78ab671ad3 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx @@ -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, @@ -153,10 +153,8 @@ export default function WorkspaceBillingClient() { /> )}
-
-
- WIP -
+
+
@@ -265,7 +263,7 @@ function UsageTabCard({ return (
)}
-
+
diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/usage-chart.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/usage-chart.tsx index 3c1b0fbb07..103512a09e 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/usage-chart.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/usage-chart.tsx @@ -2,8 +2,11 @@ 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 YAxis from "@/ui/charts/y-axis"; +import { formatDate, nFormatter } from "@dub/utils"; +import { LinearGradient } from "@visx/gradient"; import { useSearchParams } from "next/navigation"; -import { useMemo } from "react"; +import { Fragment, useMemo } from "react"; const RESOURCES = ["links", "events", "revenue"] as const; @@ -27,6 +30,7 @@ export function UsageChart() {
{chartData && chartData.length > 0 ? ( { + return ( + <> +

+ {formatDate(d.date)} +

+
+ +
+
+

{resource}

+
+

+ {resource === "revenue" && "$"} + {nFormatter(d.values.usage, { full: true })} +

+ +
+ + ); + }} > - + + + + + + + `$${nFormatter(v)}` : nFormatter + } + /> ) : null}
diff --git a/apps/web/package.json b/apps/web/package.json index 2f699df886..c28b603c0b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -41,6 +41,7 @@ "@vercel/functions": "^1.4.2", "@vercel/og": "^0.6.3", "@visx/axis": "^2.14.0", + "@visx/clip-path": "^3.3.0", "@visx/curve": "^3.3.0", "@visx/event": "^2.6.0", "@visx/geo": "^2.10.0", diff --git a/apps/web/ui/charts/bars.tsx b/apps/web/ui/charts/bars.tsx index 6dfe66c0fb..6af8d7572b 100644 --- a/apps/web/ui/charts/bars.tsx +++ b/apps/web/ui/charts/bars.tsx @@ -1,85 +1,107 @@ import { cn } from "@dub/utils"; +import { RectClipPath } from "@visx/clip-path"; 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 { useId } from "react"; import { useChartContext } from "./chart-context"; export function Bars({ - seriesClassNames, + seriesStyles, }: { - seriesClassNames?: { id: string; gradient?: string; bar?: string }[]; + seriesStyles?: { + id: string; + gradientClassName?: string; + barClassName?: string; + barFill?: string; + }[]; }) { - const { data, series, margin, xScale, yScale, height, startDate, endDate } = - useChartContext(); + const clipPathId = useId(); + const { + data, + series, + margin, + xScale, + yScale, + width, + 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 ( + {series .filter(({ isActive }) => isActive) - .map((s) => ( - // Prevent ugly x-scale animations when start/end dates change with unique key - - {/* Bar gradient */} - 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} - /> + .map((s) => { + const styles = seriesStyles?.find(({ id }) => id === s.id); + return ( + // Prevent ugly x-scale animations when start/end dates change with unique key + + {/* Bar gradient */} + - {/* Bars */} - {data.map((d) => { - const barWidth = xScale.bandwidth(); - const y = yScale(s.valueAccessor(d) ?? 0); - const barHeight = height - y; - return ( - id === s.id)?.bar, - )} - fill={`url(#${s.id}-background)`} - /> - ); - })} - - ))} + {/* Bars */} + + {data.map((d) => { + const barWidth = xScale.bandwidth(); + const x = xScale(d.date) ?? 0; + const y = yScale(s.valueAccessor(d) ?? 0); + const barHeight = height - y; + const radius = Math.min(barWidth, barHeight) / 2; + return barHeight > 0 ? ( + + ) : null; + })} + + + ); + })} ); diff --git a/apps/web/ui/charts/time-series-chart.tsx b/apps/web/ui/charts/time-series-chart.tsx index 798f4f5d96..a9a625215b 100644 --- a/apps/web/ui/charts/time-series-chart.tsx +++ b/apps/web/ui/charts/time-series-chart.tsx @@ -84,7 +84,8 @@ function TimeSeriesChartInner({ .filter((v): v is number => v != null); return { - minY: Math.min(...values), + // Start at 0 for bar charts + minY: type === "area" ? Math.min(...values) : Math.min(0, ...values), maxY: Math.max(...values), }; }, [data, series]); @@ -114,7 +115,7 @@ function TimeSeriesChartInner({ align: 0.5, }), }; - }, [startDate, endDate, minY, maxY, height, width, type]); + }, [startDate, endDate, minY, maxY, height, width, data.length, type]); const chartContext: ChartContextType = { type, @@ -162,8 +163,11 @@ function TimeSeriesChartInner({ ("bandwidth" in xScale ? ( <> ({ key={tooltipData.date.toString()} left={(tooltipLeft ?? 0) + margin.left} top={(tooltipTop ?? 0) + margin.top} - offsetLeft={8} + offsetLeft={"bandwidth" in xScale ? xScale.bandwidth() + 8 : 8} offsetTop={12} className="absolute" unstyled={true} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c2a154ab5..e06aafddf9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -119,6 +119,9 @@ importers: '@visx/axis': specifier: ^2.14.0 version: 2.14.0(react@18.2.0) + '@visx/clip-path': + specifier: ^3.3.0 + version: 3.3.0(react@18.2.0) '@visx/curve': specifier: ^3.3.0 version: 3.3.0 @@ -10028,6 +10031,16 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@visx/clip-path@3.3.0(react@18.2.0): + resolution: {integrity: sha512-uMuI2M05qZTgUdTSHJGg4VDr2gytGmGyuaC89iByHqNaeMHkrJqQi/cOFAZi4D0dn75p7lVirJijEgDgSpcrMQ==} + peerDependencies: + react: ^16.0.0-0 || ^17.0.0-0 || ^18.0.0-0 + dependencies: + '@types/react': 18.2.48 + prop-types: 15.8.1 + react: 18.2.0 + dev: false + /@visx/curve@2.1.0: resolution: {integrity: sha512-9b6JOnx91gmOQiSPhUOxdsvcnW88fgqfTPKoVgQxidMsD/I3wksixtwo8TR/vtEz2aHzzsEEhlv1qK7Y3yaSDw==} dependencies: From 5f90e797eefe701d3b079fcc2e82f46db46abe14 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Wed, 16 Oct 2024 14:31:50 -0400 Subject: [PATCH 05/12] Improve responsiveness --- .../[slug]/settings/billing/page-client.tsx | 12 ++++----- .../[slug]/settings/billing/usage-chart.tsx | 13 ++++++--- apps/web/lib/swr/use-usage.ts | 8 ++++-- apps/web/lib/zod/schemas/usage.ts | 2 +- packages/tinybird/pipes/v1_usage.pipe | 27 +++++++++++++++++++ 5 files changed, 49 insertions(+), 13 deletions(-) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx index 395ce504d9..8921962c8d 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/billing/page-client.tsx @@ -67,7 +67,7 @@ export default function WorkspaceBillingClient() { return (
-
+

Plan and Usage

@@ -123,7 +123,7 @@ export default function WorkspaceBillingClient() {

@@ -153,7 +153,7 @@ export default function WorkspaceBillingClient() { /> )}
-
+
@@ -184,7 +184,7 @@ export default function WorkspaceBillingClient() { />
-
+
{plan ? (

{plan === "enterprise" @@ -263,7 +263,7 @@ function UsageTabCard({ return (