Added the usage bar for v3
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { SimpleTooltip } from "../../primitives/Tooltip";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type UsageBarProps = {
|
||||
current: number;
|
||||
billingLimit?: number;
|
||||
tierLimit?: number;
|
||||
projectedUsage: number;
|
||||
};
|
||||
|
||||
export function UsageBar({ current, billingLimit, tierLimit, projectedUsage }: UsageBarProps) {
|
||||
const getLargestNumber = Math.max(
|
||||
current,
|
||||
tierLimit ?? -Infinity,
|
||||
projectedUsage,
|
||||
billingLimit ?? -Infinity
|
||||
);
|
||||
//creates a maximum range for the progress bar, add 10% to the largest number so the bar doesn't reach the end
|
||||
const maxRange = Math.round(getLargestNumber * 1.1);
|
||||
const tierRunLimitPercentage = tierLimit ? Math.round((tierLimit / maxRange) * 100) : 0;
|
||||
const projectedRunsPercentage = Math.round((projectedUsage / maxRange) * 100);
|
||||
const billingLimitPercentage =
|
||||
billingLimit !== undefined ? Math.round((billingLimit / maxRange) * 100) : 0;
|
||||
const usagePercentage = Math.round((current / maxRange) * 100);
|
||||
|
||||
//cap the usagePercentage to the freeRunLimitPercentage
|
||||
const usageCappedToLimitPercentage = Math.min(usagePercentage, tierRunLimitPercentage);
|
||||
|
||||
return (
|
||||
<div className="h-fit w-full py-12">
|
||||
<div className="relative h-3 w-full rounded-sm bg-charcoal-800">
|
||||
{billingLimit && (
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: billingLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${billingLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm"
|
||||
>
|
||||
<Legend
|
||||
text="Billing limit:"
|
||||
value={formatCurrency(billingLimit, false)}
|
||||
position="bottomRow2"
|
||||
percentage={billingLimitPercentage}
|
||||
tooltipContent={`Billing limit: ${formatCurrency(billingLimit, false)}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{tierLimit && (
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: tierRunLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${tierRunLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm bg-green-900/50"
|
||||
>
|
||||
<Legend
|
||||
text={`Tier limit:`}
|
||||
value={formatCurrency(tierLimit, false)}
|
||||
position="bottomRow1"
|
||||
percentage={tierRunLimitPercentage}
|
||||
tooltipContent={`Tier limit: ${formatCurrency(tierLimit, false)}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
{projectedUsage !== 0 && (
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: projectedRunsPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${projectedRunsPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm"
|
||||
>
|
||||
<Legend
|
||||
text="Projected:"
|
||||
value={formatCurrency(projectedUsage, false)}
|
||||
position="topRow2"
|
||||
percentage={projectedRunsPercentage}
|
||||
tooltipContent={`Projected runs: ${formatCurrency(projectedUsage, false)}`}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: usagePercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${usagePercentage}%` }}
|
||||
className={cn(
|
||||
"absolute h-3 rounded-l-sm",
|
||||
tierLimit && current > tierLimit ? "bg-rose-600" : "bg-green-600"
|
||||
)}
|
||||
>
|
||||
<Legend
|
||||
text="Current:"
|
||||
value={formatCurrency(current, false)}
|
||||
position="topRow1"
|
||||
percentage={usagePercentage}
|
||||
tooltipContent={`Current run count: ${formatCurrency(current, false)}`}
|
||||
/>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: usageCappedToLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${usageCappedToLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm bg-green-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const positions = {
|
||||
topRow1: "bottom-0 h-9",
|
||||
topRow2: "bottom-0 h-14",
|
||||
bottomRow1: "top-0 h-9 items-end",
|
||||
bottomRow2: "top-0 h-14 items-end",
|
||||
};
|
||||
|
||||
type LegendProps = {
|
||||
text: string;
|
||||
value: number | string;
|
||||
percentage: number;
|
||||
position: keyof typeof positions;
|
||||
tooltipContent: string;
|
||||
};
|
||||
|
||||
function Legend({ text, value, position, percentage, tooltipContent }: LegendProps) {
|
||||
const flipLegendPositionValue = 80;
|
||||
const flipLegendPosition = percentage > flipLegendPositionValue ? true : false;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-full z-10 flex border-charcoal-400",
|
||||
positions[position],
|
||||
flipLegendPosition === true ? "-translate-x-full border-r" : "border-l"
|
||||
)}
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background-dimmed px-1.5 text-xs text-text-dimmed">
|
||||
{text}
|
||||
<span className="ml-1 text-text-bright">{value}</span>
|
||||
</Paragraph>
|
||||
}
|
||||
variant="dark"
|
||||
side="top"
|
||||
content={tooltipContent}
|
||||
className="z-50 h-fit"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,26 +1,19 @@
|
||||
import { ArrowRightIcon } from "@heroicons/react/24/solid";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Label,
|
||||
Legend,
|
||||
Rectangle,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import { Bar, BarChart, Rectangle, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { UsageBar } from "~/components/billing/v3/UsageBar";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { prisma } from "~/db.server";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { getUsageSeries } from "~/services/platform.v3.server";
|
||||
import { getUsage, getUsageSeries } from "~/services/platform.v3.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { createTimeSeriesData } from "~/utils/graphs";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
import { OrganizationParamsSchema, organizationPath } from "~/utils/pathBuilder";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
await requireUserId(request);
|
||||
@@ -40,31 +33,43 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
}
|
||||
|
||||
//periods
|
||||
const periodStart = new Date();
|
||||
periodStart.setDate(periodStart.getDate() - 30);
|
||||
periodStart.setHours(0, 0, 0, 0);
|
||||
const periodEnd = new Date();
|
||||
periodEnd.setDate(periodEnd.getDate() + 1);
|
||||
periodEnd.setHours(23, 59, 59, 999);
|
||||
const thirtyDaysAgo = new Date();
|
||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
||||
thirtyDaysAgo.setHours(0, 0, 0, 0);
|
||||
const endOfToday = new Date();
|
||||
endOfToday.setDate(endOfToday.getDate() + 1);
|
||||
endOfToday.setHours(23, 59, 59, 999);
|
||||
|
||||
const creditUsage = await getUsageSeries(organization.id, {
|
||||
from: periodStart,
|
||||
to: periodEnd,
|
||||
const past30Days = await getUsageSeries(organization.id, {
|
||||
from: thirtyDaysAgo,
|
||||
to: endOfToday,
|
||||
window: "DAY",
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(creditUsage, null, 2));
|
||||
const startOfMonth = new Date();
|
||||
startOfMonth.setDate(1);
|
||||
startOfMonth.setHours(0, 0, 0, 0);
|
||||
|
||||
const now = new Date();
|
||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
|
||||
|
||||
const usageData = await getUsage(organization.id, { from: startOfMonth, to: endOfMonth });
|
||||
|
||||
const current = (usageData?.cents ?? 0) / 100;
|
||||
const percentageThroughMonth = new Date().getDate() / endOfMonth.getDate();
|
||||
const usage = {
|
||||
current: current,
|
||||
projected: current / percentageThroughMonth,
|
||||
};
|
||||
|
||||
return typedjson({
|
||||
periodStart,
|
||||
periodEnd,
|
||||
creditUsage: creditUsage
|
||||
past30Days: past30Days
|
||||
? createTimeSeriesData({
|
||||
startDate: periodStart,
|
||||
endDate: periodEnd,
|
||||
startDate: thirtyDaysAgo,
|
||||
endDate: endOfToday,
|
||||
window: "DAY",
|
||||
data:
|
||||
creditUsage.data.map((period) => ({
|
||||
past30Days.data.map((period) => ({
|
||||
date: new Date(period.windowStart),
|
||||
value: period.value,
|
||||
})) ?? [],
|
||||
@@ -73,6 +78,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
dollars: (period.value ?? 0) / 100,
|
||||
}))
|
||||
: [],
|
||||
usage,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,7 +90,7 @@ const dateFormatter = new Intl.DateTimeFormat("en-US", {
|
||||
const tooltipStyle = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "0.5rem",
|
||||
gap: "0rem",
|
||||
borderRadius: "0.25rem",
|
||||
border: "1px solid #1A2434",
|
||||
backgroundColor: "#0B1018",
|
||||
@@ -94,7 +100,8 @@ const tooltipStyle = {
|
||||
};
|
||||
|
||||
export default function ChoosePlanPage() {
|
||||
const { periodStart, periodEnd, creditUsage } = useTypedLoaderData<typeof loader>();
|
||||
const { usage, past30Days } = useTypedLoaderData<typeof loader>();
|
||||
const currentPlan = useCurrentPlan();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -102,15 +109,37 @@ export default function ChoosePlanPage() {
|
||||
<PageTitle title="Usage" />
|
||||
</NavBar>
|
||||
<PageBody scrollable={true}>
|
||||
<div className="rounded-sm border border-grid-dimmed p-3">
|
||||
<Header3>Usage (past 30 days)</Header3>
|
||||
<Header2 spacing>This month</Header2>
|
||||
<div className="flex w-full flex-col gap-2 rounded-sm border border-grid-dimmed p-4">
|
||||
<div className="flex w-full items-center gap-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Header3 className="">Month-to-date</Header3>
|
||||
<p className="text-3xl font-medium text-text-bright">
|
||||
{formatCurrency(usage.current, false)}
|
||||
</p>
|
||||
</div>
|
||||
<ArrowRightIcon className="h-6 w-6 text-text-dimmed/50" />
|
||||
<div className="flex flex-col gap-2 text-text-dimmed">
|
||||
<Header3 className="text-text-dimmed">Projected</Header3>
|
||||
<p className="text-3xl font-medium">{formatCurrency(usage.projected, false)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<UsageBar
|
||||
current={usage.current}
|
||||
projectedUsage={usage.projected}
|
||||
tierLimit={(currentPlan?.v3Subscription?.plan?.limits.includedUsage ?? 0) / 100}
|
||||
/>
|
||||
</div>
|
||||
<Header2 spacing>Past 30 days</Header2>
|
||||
<div className="rounded-sm border border-grid-dimmed p-4">
|
||||
<Header3>Usage</Header3>
|
||||
<ResponsiveContainer width="100%" height="100%" className="min-h-96">
|
||||
<BarChart
|
||||
data={creditUsage}
|
||||
data={past30Days}
|
||||
margin={{
|
||||
top: 20,
|
||||
right: 0,
|
||||
left: 0,
|
||||
left: -10,
|
||||
bottom: 10,
|
||||
}}
|
||||
>
|
||||
@@ -148,6 +177,7 @@ export default function ChoosePlanPage() {
|
||||
|
||||
return dateFormatter.format(new Date(dateString));
|
||||
}}
|
||||
formatter={(value, data) => [`$${value.toLocaleString()}`, ""]}
|
||||
/>
|
||||
<Bar dataKey="dollars" fill="#28BF5C" activeBar={<Rectangle fill="#5ADE87" />} />
|
||||
</BarChart>
|
||||
|
||||
Reference in New Issue
Block a user