adds a new /usage page that lets board operators see how much each ai provider is consuming across any date window, with per-model breakdowns, rolling 5h/24h/7d burn windows, weekly budget bars, and a deficit notch when projected spend is on track to exceed the monthly budget. - new GET /companies/:id/costs/by-provider endpoint aggregates cost events by provider + model with pro-rated billing type splits from heartbeat runs - new GET /companies/:id/costs/window-spend endpoint returns rolling window spend (5h, 24h, 7d) per provider with no schema changes - QuotaBar: reusable boxed-border progress bar with green/yellow/red threshold fill colors and optional deficit notch - ProviderQuotaCard: per-provider card showing budget allocation bars, rolling windows, subscription usage, and model breakdown with token/cost share overlays - Usage page: date preset toggles (mtd, 7d, 30d, ytd, all, custom), provider tabs, 30s polling plus ws invalidation on cost_event - custom date range blocks queries until both dates are selected and treats boundaries as local-time (not utc midnight) so full days are included regardless of timezone - query key to timestamp is floored to the nearest minute to prevent cache churn on every 30s refetch tick
87 lines
2.5 KiB
TypeScript
87 lines
2.5 KiB
TypeScript
const BOARD_ROUTE_ROOTS = new Set([
|
|
"dashboard",
|
|
"companies",
|
|
"company",
|
|
"org",
|
|
"agents",
|
|
"projects",
|
|
"issues",
|
|
"goals",
|
|
"approvals",
|
|
"costs",
|
|
"usage",
|
|
"activity",
|
|
"inbox",
|
|
"design-guide",
|
|
]);
|
|
|
|
const GLOBAL_ROUTE_ROOTS = new Set(["auth", "invite", "board-claim", "docs", "instance"]);
|
|
|
|
export function normalizeCompanyPrefix(prefix: string): string {
|
|
return prefix.trim().toUpperCase();
|
|
}
|
|
|
|
function splitPath(path: string): { pathname: string; search: string; hash: string } {
|
|
const match = path.match(/^([^?#]*)(\?[^#]*)?(#.*)?$/);
|
|
return {
|
|
pathname: match?.[1] ?? path,
|
|
search: match?.[2] ?? "",
|
|
hash: match?.[3] ?? "",
|
|
};
|
|
}
|
|
|
|
function getRootSegment(pathname: string): string | null {
|
|
const segment = pathname.split("/").filter(Boolean)[0];
|
|
return segment ?? null;
|
|
}
|
|
|
|
export function isGlobalPath(pathname: string): boolean {
|
|
if (pathname === "/") return true;
|
|
const root = getRootSegment(pathname);
|
|
if (!root) return true;
|
|
return GLOBAL_ROUTE_ROOTS.has(root.toLowerCase());
|
|
}
|
|
|
|
export function isBoardPathWithoutPrefix(pathname: string): boolean {
|
|
const root = getRootSegment(pathname);
|
|
if (!root) return false;
|
|
return BOARD_ROUTE_ROOTS.has(root.toLowerCase());
|
|
}
|
|
|
|
export function extractCompanyPrefixFromPath(pathname: string): string | null {
|
|
const segments = pathname.split("/").filter(Boolean);
|
|
if (segments.length === 0) return null;
|
|
const first = segments[0]!.toLowerCase();
|
|
if (GLOBAL_ROUTE_ROOTS.has(first) || BOARD_ROUTE_ROOTS.has(first)) {
|
|
return null;
|
|
}
|
|
return normalizeCompanyPrefix(segments[0]!);
|
|
}
|
|
|
|
export function applyCompanyPrefix(path: string, companyPrefix: string | null | undefined): string {
|
|
const { pathname, search, hash } = splitPath(path);
|
|
if (!pathname.startsWith("/")) return path;
|
|
if (isGlobalPath(pathname)) return path;
|
|
if (!companyPrefix) return path;
|
|
|
|
const prefix = normalizeCompanyPrefix(companyPrefix);
|
|
const activePrefix = extractCompanyPrefixFromPath(pathname);
|
|
if (activePrefix) return path;
|
|
|
|
return `/${prefix}${pathname}${search}${hash}`;
|
|
}
|
|
|
|
export function toCompanyRelativePath(path: string): string {
|
|
const { pathname, search, hash } = splitPath(path);
|
|
const segments = pathname.split("/").filter(Boolean);
|
|
|
|
if (segments.length >= 2) {
|
|
const second = segments[1]!.toLowerCase();
|
|
if (!GLOBAL_ROUTE_ROOTS.has(segments[0]!.toLowerCase()) && BOARD_ROUTE_ROOTS.has(second)) {
|
|
return `/${segments.slice(1).join("/")}${search}${hash}`;
|
|
}
|
|
}
|
|
|
|
return `${pathname}${search}${hash}`;
|
|
}
|