mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-20 17:47:19 +00:00
feat(desktop): add insight modes — trend chart, forecast, pulse tiles, and stats grid
Adds InsightPills mode switcher and four insight panels: 19-day bar chart with hover tooltips (TrendInsight), month-to-date + projection with prior-month delta (ForecastInsight), three efficiency tiles for cache hit / 1-shot / cost-per-session (PulseInsight), and a 2-column stats grid with streaks and lifetime total (StatsInsight). Wired into App.tsx with an insight-area section between period tabs and the activity section.
This commit is contained in:
parent
e2ebdc92e4
commit
026795cd34
7 changed files with 689 additions and 0 deletions
|
|
@ -10,6 +10,11 @@ import { PayloadCache } from './lib/cache'
|
|||
import { AgentTabStrip } from './components/AgentTabStrip'
|
||||
import type { Provider } from './components/AgentTabStrip'
|
||||
import { ModelsSection } from './components/ModelsSection'
|
||||
import { InsightPills, type InsightMode } from './components/InsightPills'
|
||||
import { TrendInsight } from './components/TrendInsight'
|
||||
import { ForecastInsight } from './components/ForecastInsight'
|
||||
import { PulseInsight } from './components/PulseInsight'
|
||||
import { StatsInsight } from './components/StatsInsight'
|
||||
|
||||
const payloadCache = new PayloadCache<MenubarPayload>()
|
||||
|
||||
|
|
@ -32,6 +37,7 @@ export function App() {
|
|||
const [currency, setCurrency] = useState<CurrencyState>(USD)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [insight, setInsight] = useState<InsightMode>('trend')
|
||||
|
||||
const refresh = useCallback(async (includeOptimize: boolean) => {
|
||||
if (!includeOptimize) {
|
||||
|
|
@ -141,6 +147,26 @@ export function App() {
|
|||
))}
|
||||
</nav>
|
||||
|
||||
<div className="insight-area">
|
||||
<InsightPills
|
||||
selected={insight}
|
||||
onSelect={setInsight}
|
||||
modes={['trend', 'forecast', 'pulse', 'stats']}
|
||||
/>
|
||||
{insight === 'trend' && (
|
||||
<TrendInsight days={payload.history.daily} currency={currency} />
|
||||
)}
|
||||
{insight === 'forecast' && (
|
||||
<ForecastInsight days={payload.history.daily} currency={currency} />
|
||||
)}
|
||||
{insight === 'pulse' && (
|
||||
<PulseInsight payload={payload} currency={currency} />
|
||||
)}
|
||||
{insight === 'stats' && (
|
||||
<StatsInsight payload={payload} currency={currency} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loading && payload.current.calls === 0 && payload.current.sessions === 0 ? (
|
||||
<section className="empty-state">
|
||||
<h2 className="section-title">No session data yet</h2>
|
||||
|
|
|
|||
98
desktop/src/components/ForecastInsight.tsx
Normal file
98
desktop/src/components/ForecastInsight.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import type { DailyEntry } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCurrency, formatCompactCurrency } from '../lib/currency'
|
||||
import {
|
||||
formatDateKey, addDays, startOfDay,
|
||||
firstOfMonth, daysInMonth, dayOfMonth,
|
||||
} from '../lib/dates'
|
||||
|
||||
type Props = {
|
||||
days: DailyEntry[]
|
||||
currency: CurrencyState
|
||||
}
|
||||
|
||||
type ForecastStats = {
|
||||
mtd: number
|
||||
projection: number
|
||||
weekAvg: number
|
||||
weekTotal: number
|
||||
yesterday: number
|
||||
previousMonthTotal: number | null
|
||||
}
|
||||
|
||||
function compute(days: DailyEntry[]): ForecastStats {
|
||||
const now = new Date()
|
||||
const fom = firstOfMonth(now)
|
||||
const fomStr = formatDateKey(fom)
|
||||
const totalDays = daysInMonth(now)
|
||||
const dom = dayOfMonth(now)
|
||||
|
||||
const mtd = days.filter(d => d.date >= fomStr).reduce((s, d) => s + d.cost, 0)
|
||||
const avgPerDay = dom > 0 ? mtd / dom : 0
|
||||
const projection = avgPerDay * totalDays
|
||||
|
||||
const today = startOfDay(now)
|
||||
const weekStartStr = formatDateKey(addDays(today, -6))
|
||||
const weekTotal = days.filter(d => d.date >= weekStartStr).reduce((s, d) => s + d.cost, 0)
|
||||
const weekAvg = weekTotal / 7
|
||||
|
||||
const yesterdayStr = formatDateKey(addDays(today, -1))
|
||||
const yesterday = days.find(d => d.date === yesterdayStr)?.cost ?? 0
|
||||
|
||||
let previousMonthTotal: number | null = null
|
||||
const prevMonth = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1))
|
||||
const prevFirstStr = formatDateKey(prevMonth)
|
||||
const prevLastDay = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 0))
|
||||
const prevLastStr = formatDateKey(prevLastDay)
|
||||
const prevEntries = days.filter(d => d.date >= prevFirstStr && d.date <= prevLastStr)
|
||||
if (prevEntries.length > 0) {
|
||||
previousMonthTotal = prevEntries.reduce((s, d) => s + d.cost, 0)
|
||||
}
|
||||
|
||||
return { mtd, projection, weekAvg, weekTotal, yesterday, previousMonthTotal }
|
||||
}
|
||||
|
||||
export function ForecastInsight({ days, currency }: Props) {
|
||||
const s = compute(days)
|
||||
const prevDelta = s.previousMonthTotal && s.previousMonthTotal > 0
|
||||
? ((s.projection - s.previousMonthTotal) / s.previousMonthTotal) * 100
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="forecast-insight">
|
||||
<div className="forecast-header">
|
||||
<div>
|
||||
<div className="forecast-sublabel">Month-to-date</div>
|
||||
<div className="forecast-mtd">{formatCurrency(s.mtd, currency)}</div>
|
||||
</div>
|
||||
<div className="forecast-right">
|
||||
<div className="forecast-sublabel">On pace for</div>
|
||||
<div className="forecast-projection">{formatCurrency(s.projection, currency)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="trend-mini-stats">
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Avg/day (this wk)</div>
|
||||
<div className="mini-stat-value">{formatCompactCurrency(s.weekAvg, currency)}</div>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Yesterday</div>
|
||||
<div className="mini-stat-value">{formatCompactCurrency(s.yesterday, currency)}</div>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Last 7d</div>
|
||||
<div className="mini-stat-value">{formatCompactCurrency(s.weekTotal, currency)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{prevDelta !== null && (
|
||||
<div className="trend-delta" style={{ marginTop: '10px' }}>
|
||||
<span className="trend-delta-arrow">{prevDelta >= 0 ? '↗' : '↘'}</span>
|
||||
{prevDelta >= 0 ? '+' : ''}{Math.round(prevDelta)}% vs last month
|
||||
({formatCompactCurrency(s.previousMonthTotal!, currency)})
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
desktop/src/components/InsightPills.tsx
Normal file
30
desktop/src/components/InsightPills.tsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
export type InsightMode = 'trend' | 'forecast' | 'pulse' | 'stats'
|
||||
|
||||
type Props = {
|
||||
selected: InsightMode
|
||||
onSelect: (m: InsightMode) => void
|
||||
modes: InsightMode[]
|
||||
}
|
||||
|
||||
const LABELS: Record<InsightMode, string> = {
|
||||
trend: 'Trend',
|
||||
forecast: 'Forecast',
|
||||
pulse: 'Pulse',
|
||||
stats: 'Stats',
|
||||
}
|
||||
|
||||
export function InsightPills({ selected, onSelect, modes }: Props) {
|
||||
return (
|
||||
<div className="insight-pills">
|
||||
{modes.map(m => (
|
||||
<button
|
||||
key={m}
|
||||
className={`insight-pill ${selected === m ? 'insight-pill-active' : ''}`}
|
||||
onClick={() => onSelect(m)}
|
||||
>
|
||||
{LABELS[m]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
desktop/src/components/PulseInsight.tsx
Normal file
32
desktop/src/components/PulseInsight.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import type { MenubarPayload } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCompactCurrency } from '../lib/currency'
|
||||
|
||||
type Props = {
|
||||
payload: MenubarPayload
|
||||
currency: CurrencyState
|
||||
}
|
||||
|
||||
export function PulseInsight({ payload, currency }: Props) {
|
||||
const { cacheHitPercent, oneShotRate, cost, sessions } = payload.current
|
||||
const cacheText = cacheHitPercent <= 0 ? '—' : `${Math.round(cacheHitPercent)}%`
|
||||
const oneShotText = oneShotRate == null ? '—' : `${Math.round(oneShotRate * 100)}%`
|
||||
const costPerSession = sessions > 0 ? formatCompactCurrency(cost / sessions, currency) : '—'
|
||||
|
||||
return (
|
||||
<div className="pulse-tiles">
|
||||
<div className="pulse-tile">
|
||||
<div className="pulse-label">Cache hit</div>
|
||||
<div className="pulse-value pulse-value-accent">{cacheText}</div>
|
||||
</div>
|
||||
<div className="pulse-tile">
|
||||
<div className="pulse-label">1-shot</div>
|
||||
<div className="pulse-value pulse-value-accent">{oneShotText}</div>
|
||||
</div>
|
||||
<div className="pulse-tile">
|
||||
<div className="pulse-label">Cost / session</div>
|
||||
<div className="pulse-value">{costPerSession}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
101
desktop/src/components/StatsInsight.tsx
Normal file
101
desktop/src/components/StatsInsight.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import type { MenubarPayload } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCurrency, formatCompactCurrency } from '../lib/currency'
|
||||
import { formatDateKey, addDays, startOfDay, firstOfMonth, daysInMonth } from '../lib/dates'
|
||||
|
||||
type Props = {
|
||||
payload: MenubarPayload
|
||||
currency: CurrencyState
|
||||
}
|
||||
|
||||
function computeStats(payload: MenubarPayload, currency: CurrencyState) {
|
||||
const history = payload.history.daily
|
||||
const now = new Date()
|
||||
const today = startOfDay(now)
|
||||
|
||||
const favoriteModel = payload.current.topModels[0]?.name ?? '—'
|
||||
|
||||
const fom = firstOfMonth(now)
|
||||
const fomStr = formatDateKey(fom)
|
||||
const mtdActive = history.filter(d => d.date >= fomStr && d.cost > 0).length
|
||||
const activeDaysFraction = `${mtdActive}/${daysInMonth(now)}`
|
||||
|
||||
const peak = history.reduce<{ date: string; cost: number } | null>(
|
||||
(best, d) => (!best || d.cost > best.cost) ? d : best, null
|
||||
)
|
||||
const mostActiveDay = peak && peak.cost > 0
|
||||
? new Date(peak.date + 'T00:00:00Z').toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' })
|
||||
: '—'
|
||||
const peakDaySpend = peak && peak.cost > 0 ? formatCompactCurrency(peak.cost, currency) : '—'
|
||||
|
||||
const costByDate = new Map(history.map(d => [d.date, d.cost]))
|
||||
let currentStreak = 0
|
||||
for (let i = 0; i < 400; i++) {
|
||||
const key = formatDateKey(addDays(today, -i))
|
||||
if ((costByDate.get(key) ?? 0) > 0) currentStreak++
|
||||
else break
|
||||
}
|
||||
|
||||
let longestStreak = 0
|
||||
let running = 0
|
||||
const sorted = [...history].sort((a, b) => a.date.localeCompare(b.date))
|
||||
for (const d of sorted) {
|
||||
if (d.cost > 0) { running++; longestStreak = Math.max(longestStreak, running) }
|
||||
else running = 0
|
||||
}
|
||||
|
||||
const lifetimeTotal = history.length > 0 ? history.reduce((s, d) => s + d.cost, 0) : null
|
||||
|
||||
return {
|
||||
favoriteModel,
|
||||
activeDaysFraction,
|
||||
mostActiveDay,
|
||||
peakDaySpend,
|
||||
currentStreak: currentStreak > 0 ? `${currentStreak} days` : '—',
|
||||
longestStreak: longestStreak > 0 ? `${longestStreak} days` : '—',
|
||||
lifetimeTotal,
|
||||
historyDayCount: history.length,
|
||||
}
|
||||
}
|
||||
|
||||
export function StatsInsight({ payload, currency }: Props) {
|
||||
const s = computeStats(payload, currency)
|
||||
|
||||
return (
|
||||
<div className="stats-insight">
|
||||
<div className="stats-grid">
|
||||
<div className="stats-col">
|
||||
<StatRow label="Favorite model" value={s.favoriteModel} />
|
||||
<StatRow label="Active days (month)" value={s.activeDaysFraction} />
|
||||
<StatRow label="Most active day" value={s.mostActiveDay} />
|
||||
<StatRow label="Peak day spend" value={s.peakDaySpend} />
|
||||
</div>
|
||||
<div className="stats-col">
|
||||
<StatRow label="Sessions today" value={`${payload.current.sessions}`} />
|
||||
<StatRow label="Calls today" value={payload.current.calls.toLocaleString()} />
|
||||
<StatRow label="Current streak" value={s.currentStreak} />
|
||||
<StatRow label="Longest streak" value={s.longestStreak} />
|
||||
</div>
|
||||
</div>
|
||||
{s.lifetimeTotal !== null && (
|
||||
<div className="stats-lifetime">
|
||||
<span className="stats-lifetime-label">
|
||||
Tracked spend (last {s.historyDayCount} days)
|
||||
</span>
|
||||
<span className="stats-lifetime-value">
|
||||
{formatCurrency(s.lifetimeTotal, currency)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="stat-row">
|
||||
<div className="stat-row-label">{label}</div>
|
||||
<div className="stat-row-value">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
156
desktop/src/components/TrendInsight.tsx
Normal file
156
desktop/src/components/TrendInsight.tsx
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import { useState } from 'react'
|
||||
import type { DailyEntry } from '../lib/payload'
|
||||
import type { CurrencyState } from '../lib/currency'
|
||||
import { formatCompactCurrency, formatCurrency } from '../lib/currency'
|
||||
import { todayKey, formatDateKey, addDays, startOfDay, prettyDate, shortDate } from '../lib/dates'
|
||||
|
||||
const TREND_DAYS = 19
|
||||
|
||||
type TrendBar = {
|
||||
date: string
|
||||
cost: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
isToday: boolean
|
||||
topModels: Array<{ name: string; totalTokens?: number; inputTokens?: number; outputTokens?: number }>
|
||||
}
|
||||
|
||||
function formatTokens(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}K`
|
||||
return `${Math.round(n)}`
|
||||
}
|
||||
|
||||
function buildBars(days: DailyEntry[]): TrendBar[] {
|
||||
const byDate = new Map(days.map(d => [d.date, d]))
|
||||
const today = startOfDay(new Date())
|
||||
const tk = todayKey()
|
||||
const bars: TrendBar[] = []
|
||||
for (let i = TREND_DAYS - 1; i >= 0; i--) {
|
||||
const d = addDays(today, -i)
|
||||
const key = formatDateKey(d)
|
||||
const entry = byDate.get(key)
|
||||
bars.push({
|
||||
date: key,
|
||||
cost: entry?.cost ?? 0,
|
||||
inputTokens: entry?.inputTokens ?? 0,
|
||||
outputTokens: entry?.outputTokens ?? 0,
|
||||
isToday: key === tk,
|
||||
topModels: entry?.topModels ?? [],
|
||||
})
|
||||
}
|
||||
return bars
|
||||
}
|
||||
|
||||
function computeDelta(bars: TrendBar[], allDays: DailyEntry[]): number | null {
|
||||
const thisTotal = bars.reduce((s, b) => s + b.cost, 0)
|
||||
const today = startOfDay(new Date())
|
||||
const priorStart = formatDateKey(addDays(today, -(2 * TREND_DAYS - 1)))
|
||||
const thisStart = formatDateKey(addDays(today, -(TREND_DAYS - 1)))
|
||||
const priorTotal = allDays
|
||||
.filter(d => d.date >= priorStart && d.date < thisStart)
|
||||
.reduce((s, d) => s + d.cost, 0)
|
||||
if (priorTotal <= 0) return null
|
||||
return ((thisTotal - priorTotal) / priorTotal) * 100
|
||||
}
|
||||
|
||||
type Props = {
|
||||
days: DailyEntry[]
|
||||
currency: CurrencyState
|
||||
}
|
||||
|
||||
export function TrendInsight({ days, currency }: Props) {
|
||||
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null)
|
||||
const bars = buildBars(days)
|
||||
const totalTokens = bars.reduce((s, b) => s + b.inputTokens + b.outputTokens, 0)
|
||||
const useTokens = totalTokens > 0
|
||||
const metric = (b: TrendBar) => useTokens ? b.inputTokens + b.outputTokens : b.cost
|
||||
const maxVal = Math.max(...bars.map(metric), 0.01)
|
||||
const avgVal = bars.length > 0 ? bars.reduce((s, b) => s + metric(b), 0) / bars.length : 0
|
||||
const totalCost = bars.reduce((s, b) => s + b.cost, 0)
|
||||
const peak = bars.filter(b => metric(b) > 0).sort((a, b) => metric(b) - metric(a))[0]
|
||||
const yesterday = bars.find(b => {
|
||||
const yd = formatDateKey(addDays(startOfDay(new Date()), -1))
|
||||
return b.date === yd
|
||||
})
|
||||
const delta = computeDelta(bars, days)
|
||||
|
||||
const fmtVal = (v: number) => useTokens ? `${formatTokens(v)} tok` : formatCompactCurrency(v, currency)
|
||||
const heroText = useTokens ? `${formatTokens(totalTokens)} tokens` : formatCurrency(totalCost, currency)
|
||||
|
||||
return (
|
||||
<div className="trend-insight">
|
||||
<div className="trend-header">
|
||||
<div className="trend-header-left">
|
||||
<div className="trend-sublabel">Last {TREND_DAYS} days</div>
|
||||
<div className="trend-hero-value">{heroText}</div>
|
||||
</div>
|
||||
{delta !== null && (
|
||||
<div className="trend-delta">
|
||||
<span className="trend-delta-arrow">{delta >= 0 ? '↗' : '↘'}</span>
|
||||
{delta >= 0 ? '+' : ''}{Math.round(delta)}% vs prior {TREND_DAYS}d
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="trend-chart" onMouseLeave={() => setHoveredIdx(null)}>
|
||||
<div className="trend-bars">
|
||||
{bars.map((bar, i) => {
|
||||
const val = metric(bar)
|
||||
const pct = maxVal > 0 ? (val / maxVal) * 100 : 0
|
||||
const isHovered = hoveredIdx === i
|
||||
return (
|
||||
<div
|
||||
key={bar.date}
|
||||
className="trend-bar-col"
|
||||
onMouseEnter={() => setHoveredIdx(i)}
|
||||
>
|
||||
<div className="trend-bar-spacer" />
|
||||
<div
|
||||
className={`trend-bar ${bar.isToday ? 'trend-bar-today' : ''} ${isHovered ? 'trend-bar-hovered' : ''}`}
|
||||
style={{ height: `${Math.max(2, pct)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
className="trend-avg-line"
|
||||
style={{ bottom: `${Math.min((avgVal / maxVal) * 100, 100)}%` }}
|
||||
/>
|
||||
{hoveredIdx !== null && bars[hoveredIdx] && (
|
||||
<div className="trend-tooltip">
|
||||
<div className="trend-tooltip-header">
|
||||
<span>{prettyDate(bars[hoveredIdx].date)}</span>
|
||||
<span className="trend-tooltip-value">{fmtVal(metric(bars[hoveredIdx]))}</span>
|
||||
</div>
|
||||
{bars[hoveredIdx].topModels.slice(0, 4).map(m => (
|
||||
<div key={m.name} className="trend-tooltip-model">
|
||||
<span className="trend-tooltip-dot" />
|
||||
<span className="trend-tooltip-name">{m.name}</span>
|
||||
<span className="trend-tooltip-tokens">{formatTokens(m.totalTokens ?? 0)} tok</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="trend-mini-stats">
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Avg/day</div>
|
||||
<div className="mini-stat-value">{fmtVal(avgVal)}</div>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Peak</div>
|
||||
<div className="mini-stat-value">
|
||||
{peak ? `${fmtVal(metric(peak))} on ${shortDate(peak.date)}` : '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mini-stat">
|
||||
<div className="mini-stat-label">Yesterday</div>
|
||||
<div className="mini-stat-value">{yesterday ? fmtVal(metric(yesterday)) : '—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -342,3 +342,249 @@ html, body, #root {
|
|||
font-size: 11px;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* ---- insight pills ---- */
|
||||
.insight-pills {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 0 var(--spacing-lg);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.insight-pill {
|
||||
border: 0;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.insight-pill-active {
|
||||
background: var(--brand-accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ---- insight area ---- */
|
||||
.insight-area {
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* ---- trend ---- */
|
||||
.trend-insight { padding: 0; }
|
||||
.trend-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.trend-sublabel {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.trend-hero-value {
|
||||
font-family: var(--font-rounded);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.trend-delta {
|
||||
font-size: 10.5px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--brand-accent);
|
||||
}
|
||||
.trend-delta-arrow {
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
margin-right: 3px;
|
||||
}
|
||||
.trend-chart {
|
||||
position: relative;
|
||||
height: 90px;
|
||||
}
|
||||
.trend-bars {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
height: 100%;
|
||||
}
|
||||
.trend-bar-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 13px;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
.trend-bar-spacer { flex: 1; }
|
||||
.trend-bar {
|
||||
width: 100%;
|
||||
background: rgba(201, 82, 29, 0.55);
|
||||
border-radius: 2px;
|
||||
min-height: 2px;
|
||||
transition: transform 0.12s ease-out;
|
||||
}
|
||||
.trend-bar-today { background: var(--brand-accent); }
|
||||
.trend-bar-hovered {
|
||||
background: rgba(201, 82, 29, 0.85);
|
||||
transform: scaleX(1.08);
|
||||
}
|
||||
.trend-avg-line {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-top: 1px dashed rgba(128, 128, 128, 0.5);
|
||||
pointer-events: none;
|
||||
}
|
||||
.trend-tooltip {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--text-primary);
|
||||
color: var(--surface);
|
||||
border-radius: 8px;
|
||||
padding: 11px;
|
||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.35);
|
||||
z-index: 10;
|
||||
}
|
||||
.trend-tooltip-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.trend-tooltip-value { color: var(--brand-accent); font-family: var(--font-mono); font-size: 10.5px; }
|
||||
.trend-tooltip-model {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 10px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
.trend-tooltip-dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: rgba(201, 82, 29, 0.7);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.trend-tooltip-name { flex: 1; font-weight: 500; }
|
||||
.trend-tooltip-tokens { font-family: var(--font-mono); font-size: 9.5px; opacity: 0.7; }
|
||||
.trend-mini-stats {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.mini-stat { flex: 1; }
|
||||
.mini-stat-label {
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.mini-stat-value {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ---- forecast ---- */
|
||||
.forecast-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.forecast-sublabel {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.forecast-mtd {
|
||||
font-family: var(--font-rounded);
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--brand-accent);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.forecast-right { text-align: right; }
|
||||
.forecast-projection {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ---- pulse ---- */
|
||||
.pulse-tiles {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.pulse-tile {
|
||||
flex: 1;
|
||||
padding: 8px 10px;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.pulse-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.pulse-value {
|
||||
font-family: var(--font-rounded);
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 3px;
|
||||
}
|
||||
.pulse-value-accent { color: var(--brand-accent); }
|
||||
|
||||
/* ---- stats ---- */
|
||||
.stats-grid {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
.stats-col { flex: 1; }
|
||||
.stat-row { margin-bottom: 8px; }
|
||||
.stat-row-label {
|
||||
font-size: 9.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.stat-row-value {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-primary);
|
||||
margin-top: 1px;
|
||||
}
|
||||
.stats-lifetime {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 8px;
|
||||
margin-top: 2px;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.stats-lifetime-label {
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.stats-lifetime-value {
|
||||
font-family: var(--font-rounded);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--brand-accent);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue