TypeScript 97.4%
SQL 1%
JavaScript 0.9%
CSS 0.6%
1"use client";2import * as React from "react";3import Link from "next/link";4import { ArrowRight, PiggyBank, TrendingDown, TrendingUp, Minus, Info } from "lucide-react";5import { ProviderIcon } from "@/components/brand/provider-icon";6import { Tooltip } from "@/components/ui/tooltip";7import { cn, formatUsd, formatNumber } from "@/lib/utils";8import type { SavingsOpportunity, UsageProjection, UsageKpis } from "./types";910/** "Estimated monthly cost" — daily average over the observed part of the range × 30, plus the trend vs the previous period. */11export function ProjectionCard({ projection, kpis, rangeDays, className }: { projection: UsageProjection; kpis: UsageKpis; rangeDays: number; className?: string }) {12 const trend = projection.costTrendPct;13 const Icon = trend === null ? Minus : trend > 2 ? TrendingUp : trend < -2 ? TrendingDown : Minus;14 const tone = trend === null || Math.abs(trend) <= 2 ? "text-fg-muted" : trend > 0 ? "text-danger" : "text-success";15 const days = projection.observedDays;16 return (17 <section className={cn("panel flex flex-col p-4", className)} aria-label="Projected monthly cost">18 <header className="flex items-start justify-between gap-3">19 <div>20 <h3 className="text-[13px] font-semibold tracking-tight">Estimated monthly cost</h3>21 <p className="text-[11px] text-fg-subtle">If the last {days < 1 ? "hours" : `${Math.round(days)} day${Math.round(days) === 1 ? "" : "s"}`} continued for 30 days</p>22 </div>23 <Tooltip content="Daily average of the estimated cost over the observed part of this range, multiplied by 30. Based on public list prices; your provider invoice may differ.">24 <span className="text-fg-subtle">25 <Info className="size-3.5" />26 </span>27 </Tooltip>28 </header>29 <p className="mt-3 text-[32px] font-semibold leading-none tracking-tight">{formatUsd(projection.estimatedMonthlyCost)}</p>30 <dl className="mt-4 grid grid-cols-2 gap-3 text-xs">31 <div>32 <dt className="text-fg-subtle">Daily average</dt>33 <dd className="mt-0.5 font-medium">{formatUsd(projection.dailyAverageCost, { precise: projection.dailyAverageCost < 0.01 })}</dd>34 </div>35 <div>36 <dt className="text-fg-subtle">This period</dt>37 <dd className="mt-0.5 font-medium">{formatUsd(kpis.costUsd)}</dd>38 </div>39 <div className="col-span-2 flex items-center gap-1.5 border-t border-hairline pt-3">40 <Icon className={cn("size-3.5", tone)} />41 {projection.previous ? (42 <span className="text-fg-muted">43 {trend === null ? (44 <>No spend in the previous {rangeDays}-day period ({formatNumber(projection.previous.requests)} requests).</>45 ) : (46 <>47 <span className={cn("font-medium", tone)}>48 {trend > 0 ? "+" : ""}49 {trend.toFixed(Math.abs(trend) >= 100 ? 0 : 1)}%50 </span>{" "}51 vs previous period ({formatUsd(projection.previous.costUsd)})52 </>53 )}54 </span>55 ) : (56 <span className="text-fg-muted">No previous period to compare against.</span>57 )}58 </div>59 </dl>60 </section>61 );62}6364/** Savings opportunities computed from real records + registry pricing (top 3). Each row deep-links to the model catalog. */65export function SavingsCard({ savings, className, loading }: { savings: SavingsOpportunity[]; className?: string; loading?: boolean }) {66 const total = savings.reduce((a, s) => a + s.savingsUsd, 0);67 return (68 <section className={cn("panel flex flex-col p-4", className)} aria-label="Savings opportunities">69 <header className="flex items-start justify-between gap-3">70 <div>71 <h3 className="text-[13px] font-semibold tracking-tight">Savings opportunities</h3>72 <p className="text-[11px] text-fg-subtle">Same tokens, cheaper sibling on the same provider</p>73 </div>74 <span className="flex size-8 items-center justify-center rounded-lg bg-success-soft text-success">75 <PiggyBank className="size-4" />76 </span>77 </header>78 {loading ? (79 <p className="mt-4 text-xs text-fg-subtle">Analysing…</p>80 ) : savings.length === 0 ? (81 <div className="mt-4 rounded-lg bg-bg-elevated/60 p-3 text-xs leading-5 text-fg-muted">82 Nothing to save right now. We look for a model on the same provider with the same capabilities that would have cost at least 40 % less for the traffic you actually sent — none qualifies for this period.83 </div>84 ) : (85 <>86 <p className="mt-3 text-[26px] font-semibold leading-none tracking-tight text-success">~{formatUsd(total)}</p>87 <p className="mt-1 text-xs text-fg-subtle">could have been saved in this period</p>88 <ul className="mt-4 space-y-3">89 {savings.map((s) => (90 <li key={`${s.fromModelKey}→${s.toModelKey}`} className="rounded-lg bg-bg-elevated/60 p-3">91 <p className="text-[13px] leading-5">92 <span className="inline-flex items-center gap-1 align-middle">93 <ProviderIcon provider={s.provider} size={13} />94 <span className="font-medium">{s.fromLabel}</span>95 <ArrowRight className="size-3 text-fg-subtle" />96 <span className="font-medium">{s.toLabel}</span>97 </span>98 </p>99 <p className="mt-1 text-xs text-fg-muted">100 Switching eligible tasks could have saved <span className="font-medium text-success">~{formatUsd(s.savingsUsd)}</span> ({s.savingsPct.toFixed(0)} %) across {formatNumber(s.requests)} request{s.requests === 1 ? "" : "s"}.101 </p>102 <details className="mt-1.5 text-[11px] text-fg-subtle">103 <summary className="tap cursor-pointer select-none hover:text-fg-muted">Why this sibling?</summary>104 <p className="mt-1 leading-4">{s.rationale}</p>105 <p className="mt-1">106 {formatUsd(s.currentCostUsd)} at {s.fromLabel} list prices vs {formatUsd(s.alternativeCostUsd)} at {s.toLabel}.107 </p>108 </details>109 <Link href={`/app/models?q=${encodeURIComponent(s.toLabel)}`} className="tap mt-1.5 inline-flex items-center gap-1 text-xs font-medium text-accent hover:underline">110 Compare in the catalog <ArrowRight className="size-3" />111 </Link>112 </li>113 ))}114 </ul>115 </>116 )}117 <p className="mt-3 text-[11px] leading-4 text-fg-subtle">Estimates only — quality differs between tiers. Reasoning, vision, tools and structured output must all be supported by the sibling; context windows are checked against your average prompt.</p>118 </section>119 );120}121