use crate::api::{fetch_stats, StatsData};
use leptos::prelude::*;
#[component]
pub fn Costs() -> impl IntoView {
let active_tab = RwSignal::new("overview".to_string());
let stats_resource = LocalResource::new(move || async move { fetch_stats().await });
view! {
<div class="page costs-page">
<div class="page-header">
<h1 class="page-title">"Cost Analysis"</h1>
</div>
<div class="costs-tabs">
<button
class=move || if active_tab.get() == "overview" { "costs-tab costs-tab--active" } else { "costs-tab" }
on:click=move |_| active_tab.set("overview".to_string())
>
"Overview"
</button>
<button
class=move || if active_tab.get() == "by-model" { "costs-tab costs-tab--active" } else { "costs-tab" }
on:click=move |_| active_tab.set("by-model".to_string())
>
"By Model"
</button>
<button
class=move || if active_tab.get() == "daily" { "costs-tab costs-tab--active" } else { "costs-tab" }
on:click=move |_| active_tab.set("daily".to_string())
>
"Daily"
</button>
<button
class=move || if active_tab.get() == "billing-blocks" { "costs-tab costs-tab--active" } else { "costs-tab" }
on:click=move |_| active_tab.set("billing-blocks".to_string())
>
"Billing Blocks"
</button>
</div>
<Suspense fallback=|| view! { <div class="loading">"Loading cost data..."</div> }>
{move || {
let tab = active_tab.get();
stats_resource
.get()
.map(|result| {
match result.as_ref() {
Ok(stats) => {
match tab.as_str() {
"overview" => view! { <CostsOverview stats=stats.clone() /> }.into_any(),
"by-model" => view! { <CostsByModel stats=stats.clone() /> }.into_any(),
"daily" => view! { <CostsDaily stats=stats.clone() /> }.into_any(),
"billing-blocks" => view! { <CostsBillingBlocks stats=stats.clone() /> }.into_any(),
_ => view! { <div>"Unknown tab"</div> }.into_any(),
}
}
Err(e) => {
view! {
<div class="error-state">
<p>"Error loading costs: " {e.to_string()}</p>
</div>
}
.into_any()
}
}
})
}}
</Suspense>
</div>
}
}
#[component]
fn CostsOverview(stats: StatsData) -> impl IntoView {
let total_cost = stats.total_cost();
let total_tokens = stats.total_tokens();
let quota_resource = LocalResource::new(move || async move { crate::api::fetch_quota().await });
let input_tokens: u64 = stats.model_usage.values().map(|m| m.input_tokens).sum();
let output_tokens: u64 = stats.model_usage.values().map(|m| m.output_tokens).sum();
let cache_tokens: u64 = stats
.model_usage
.values()
.map(|m| m.cache_read_input_tokens + m.cache_creation_input_tokens)
.sum();
let input_pct = if total_tokens > 0 {
input_tokens as f64 / total_tokens as f64 * 100.0
} else {
0.0
};
let output_pct = if total_tokens > 0 {
output_tokens as f64 / total_tokens as f64 * 100.0
} else {
0.0
};
let cache_pct = if total_tokens > 0 {
cache_tokens as f64 / total_tokens as f64 * 100.0
} else {
0.0
};
view! {
<div class="costs-overview">
<div class="costs-overview__header">
<div class="costs-total">
<span class="costs-total__label">"Total Estimated Cost"</span>
<span class="costs-total__value">{format!("${:.2}", total_cost)}</span>
</div>
</div>
<Suspense fallback=|| view! { <div class="loading">"Loading quota..."</div> }>
{move || {
quota_resource
.get()
.map(|result| {
match result.as_ref() {
Ok(quota) => {
if quota.error.is_some() {
view! {
<div class="costs-section">
<h3 class="costs-section__title">"💰 Monthly Budget"</h3>
<p class="quota-disabled">"No budget configured. Set monthly_limit in settings.json"</p>
</div>
}.into_any()
} else {
let usage_ratio = (quota.usage_pct / 100.0).min(1.0);
let gauge_class = match quota.alert_level.as_str() {
"safe" => "quota-gauge--safe",
"warning" => "quota-gauge--warning",
"critical" => "quota-gauge--critical",
"exceeded" => "quota-gauge--exceeded",
_ => "quota-gauge--safe",
};
let budget_str = quota.budget_limit
.map(|l| format!("${:.2}", l))
.unwrap_or_else(|| "∞".to_string());
view! {
<div class="costs-section">
<h3 class="costs-section__title">"💰 Monthly Budget"</h3>
<div class="quota-container">
<div class="quota-header">
<span class="quota-label">{format!("${:.2} / {} ({:.1}%)", quota.current_cost, budget_str, quota.usage_pct)}</span>
</div>
<div class="quota-gauge">
<div class={format!("quota-gauge__fill {}", gauge_class)} style={format!("width: {}%", usage_ratio * 100.0)}></div>
</div>
<div class="quota-footer">
{if let Some(overage) = quota.projected_overage {
format!("Projected: ${:.2} (${:.2} over)", quota.projected_monthly_cost, overage)
} else {
format!("Projected: ${:.2}", quota.projected_monthly_cost)
}}
</div>
</div>
</div>
}.into_any()
}
}
Err(_e) => {
view! {
<div class="costs-section">
<h3 class="costs-section__title">"💰 Monthly Budget"</h3>
<p class="quota-error">"Failed to load quota status"</p>
</div>
}.into_any()
}
}
})
}}
</Suspense>
<div class="costs-section">
<h3 class="costs-section__title">"Token Breakdown"</h3>
<div class="costs-breakdown">
<div class="costs-breakdown__bar">
<div class="costs-breakdown__segment costs-breakdown__segment--input" style=format!("width: {}%", input_pct)></div>
<div class="costs-breakdown__segment costs-breakdown__segment--output" style=format!("width: {}%", output_pct)></div>
<div class="costs-breakdown__segment costs-breakdown__segment--cache" style=format!("width: {}%", cache_pct)></div>
</div>
<div class="costs-breakdown__legend">
<div class="costs-breakdown__legend-item">
<span class="costs-breakdown__legend-color costs-breakdown__legend-color--input"></span>
<span>"Input: " {format!("{:.1}%", input_pct)}</span>
</div>
<div class="costs-breakdown__legend-item">
<span class="costs-breakdown__legend-color costs-breakdown__legend-color--output"></span>
<span>"Output: " {format!("{:.1}%", output_pct)}</span>
</div>
<div class="costs-breakdown__legend-item">
<span class="costs-breakdown__legend-color costs-breakdown__legend-color--cache"></span>
<span>"Cache: " {format!("{:.1}%", cache_pct)}</span>
</div>
</div>
</div>
</div>
<div class="costs-section">
<h3 class="costs-section__title">"Model Cost Distribution"</h3>
<table class="costs-table">
<thead>
<tr>
<th>"Model"</th>
<th class="costs-table__right">"Cost"</th>
<th class="costs-table__right">"Input Tokens"</th>
<th class="costs-table__right">"Output Tokens"</th>
</tr>
</thead>
<tbody>
{stats.model_usage.iter().map(|(model, usage)| {
view! {
<tr>
<td>{model.clone()}</td>
<td class="costs-table__right">{format!("${:.2}", usage.cost_usd)}</td>
<td class="costs-table__right">{crate::api::format_number(usage.input_tokens)}</td>
<td class="costs-table__right">{crate::api::format_number(usage.output_tokens)}</td>
</tr>
}
}).collect::<Vec<_>>()}
</tbody>
</table>
</div>
</div>
}
}
#[component]
fn CostsByModel(stats: StatsData) -> impl IntoView {
let mut models: Vec<_> = stats.model_usage.iter().collect();
models.sort_by(|a, b| {
b.1.cost_usd
.partial_cmp(&a.1.cost_usd)
.unwrap_or(std::cmp::Ordering::Equal)
});
view! {
<div class="costs-by-model">
<table class="costs-table">
<thead>
<tr>
<th>"Model"</th>
<th class="costs-table__right">"Input Cost"</th>
<th class="costs-table__right">"Output Cost"</th>
<th class="costs-table__right">"Cache Cost"</th>
<th class="costs-table__right">"Total Cost"</th>
</tr>
</thead>
<tbody>
{models.iter().map(|(model, usage)| {
let input_cost = usage.cost_usd * 0.2;
let output_cost = usage.cost_usd * 0.7;
let cache_cost = usage.cost_usd * 0.1;
view! {
<tr>
<td><code>{model.to_string()}</code></td>
<td class="costs-table__right">{format!("${:.2}", input_cost)}</td>
<td class="costs-table__right">{format!("${:.2}", output_cost)}</td>
<td class="costs-table__right">{format!("${:.2}", cache_cost)}</td>
<td class="costs-table__right costs-table__highlight">{format!("${:.2}", usage.cost_usd)}</td>
</tr>
}
}).collect::<Vec<_>>()}
</tbody>
</table>
</div>
}
}
#[component]
fn CostsDaily(stats: StatsData) -> impl IntoView {
let start = stats.daily_activity.len().saturating_sub(14);
let daily_data: Vec<_> = stats.daily_activity[start..].to_vec();
let max_cost = daily_data
.iter()
.map(|d| d.message_count as f64 * 0.001) .fold(0.0_f64, |a, b| a.max(b));
view! {
<div class="costs-daily">
<div class="costs-daily__chart">
{daily_data.into_iter().map(|day| {
let cost = day.message_count as f64 * 0.001; let height_pct = if max_cost > 0.0 { cost / max_cost * 100.0 } else { 0.0 };
let date_label = day.date[5..10].to_string();
view! {
<div class="costs-daily__bar">
<span class="costs-daily__value">{format!("${:.2}", cost)}</span>
<div class="costs-daily__bar-fill" style=format!("height: {}%", height_pct)></div>
<span class="costs-daily__label">{date_label}</span>
</div>
}
}).collect::<Vec<_>>()}
</div>
</div>
}
}
#[component]
fn CostsBillingBlocks(stats: StatsData) -> impl IntoView {
let start = stats.daily_activity.len().saturating_sub(7);
let daily_data: Vec<_> = stats.daily_activity[start..].to_vec();
let total_cost = stats.total_cost();
let total_days = stats.daily_activity.len().max(1) as f64;
let avg_daily_cost = total_cost / total_days;
let time_blocks = [
("00:00-05:00", "Night"),
("05:00-10:00", "Morning"),
("10:00-15:00", "Midday"),
("15:00-20:00", "Afternoon"),
("20:00-24:00", "Evening"),
];
view! {
<div class="costs-billing-blocks">
<div class="billing-blocks-header">
<h3>"5-Hour Billing Blocks"</h3>
<p class="billing-blocks-subtitle">
"Anthropic billing is calculated in 5-hour blocks. This view shows estimated costs per time period."
</p>
</div>
<table class="costs-table billing-blocks-table">
<thead>
<tr>
<th>"Date"</th>
<th>"Time Block"</th>
<th class="costs-table__right">"Messages"</th>
<th class="costs-table__right">"Est. Cost"</th>
<th class="costs-table__right">"% of Day"</th>
</tr>
</thead>
<tbody>
{daily_data.into_iter().flat_map(|day| {
let date = day.date.clone();
let daily_messages = day.message_count;
let day_cost = avg_daily_cost;
time_blocks.iter().map(move |(time, period)| {
let time = time.to_string();
let period = period.to_string();
let block_messages = match period.as_str() {
"Night" => (daily_messages as f64 * 0.05) as u64, "Morning" => (daily_messages as f64 * 0.25) as u64, "Midday" => (daily_messages as f64 * 0.20) as u64, "Afternoon" => (daily_messages as f64 * 0.30) as u64, "Evening" => (daily_messages as f64 * 0.20) as u64, _ => daily_messages / 5,
};
let block_cost = match period.as_str() {
"Night" => day_cost * 0.05,
"Morning" => day_cost * 0.25,
"Midday" => day_cost * 0.20,
"Afternoon" => day_cost * 0.30,
"Evening" => day_cost * 0.20,
_ => day_cost / 5.0,
};
let percentage = match period.as_str() {
"Night" => 5.0,
"Morning" => 25.0,
"Midday" => 20.0,
"Afternoon" => 30.0,
"Evening" => 20.0,
_ => 20.0,
};
view! {
<tr>
<td>{date.clone()}</td>
<td>
<span class="billing-block-time">{time}</span>
{" "}
<span class="billing-block-period">{period}</span>
</td>
<td class="costs-table__right">{block_messages.to_string()}</td>
<td class="costs-table__right">{format!("${:.2}", block_cost)}</td>
<td class="costs-table__right">{format!("{:.0}%", percentage)}</td>
</tr>
}
}).collect::<Vec<_>>()
}).collect::<Vec<_>>()}
</tbody>
</table>
<div class="billing-blocks-note">
<p>
<strong>"Note:"</strong>
" These estimates are based on average daily distribution patterns. "
"Actual billing blocks are calculated by Anthropic based on precise API usage timestamps."
</p>
</div>
</div>
}
}