mirror of
https://github.com/stablyai/orca
synced 2026-04-21 14:17:16 +00:00
feat(stats): add share button to usage panes (#874)
This commit is contained in:
parent
25971cd32d
commit
ff16ab1565
7 changed files with 728 additions and 0 deletions
|
|
@ -80,6 +80,7 @@
|
|||
"electron-updater": "^6.8.3",
|
||||
"github-slugger": "^2.0.0",
|
||||
"hosted-git-info": "^9.0.2",
|
||||
"html-to-image": "^1.11.13",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mermaid": "^11.14.0",
|
||||
|
|
|
|||
|
|
@ -127,6 +127,9 @@ importers:
|
|||
hosted-git-info:
|
||||
specifier: ^9.0.2
|
||||
version: 9.0.2
|
||||
html-to-image:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
lowlight:
|
||||
specifier: ^3.3.0
|
||||
version: 3.3.0
|
||||
|
|
@ -4050,6 +4053,9 @@ packages:
|
|||
resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==}
|
||||
engines: {node: ^20.17.0 || >=22.9.0}
|
||||
|
||||
html-to-image@1.11.13:
|
||||
resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==}
|
||||
|
||||
html-url-attributes@3.0.1:
|
||||
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
|
||||
|
||||
|
|
@ -9997,6 +10003,8 @@ snapshots:
|
|||
dependencies:
|
||||
lru-cache: 11.2.7
|
||||
|
||||
html-to-image@1.11.13: {}
|
||||
|
||||
html-url-attributes@3.0.1: {}
|
||||
|
||||
http-cache-semantics@4.2.0: {}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import {
|
|||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import { ClaudeUsageDailyChart } from './ClaudeUsageDailyChart'
|
||||
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
|
||||
import { ShareUsageButton } from './ShareUsageButton'
|
||||
import { StatCard } from './StatCard'
|
||||
|
||||
const RANGE_OPTIONS: ClaudeUsageRange[] = ['7d', '30d', '90d', 'all']
|
||||
|
|
@ -137,6 +138,9 @@ export function ClaudeUsagePane(): React.JSX.Element {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 self-start">
|
||||
{summary && daily.length > 0 && (
|
||||
<ShareUsageButton provider="claude" summary={summary} daily={daily} range={range} />
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import { ClaudeUsageLoadingState } from './ClaudeUsageLoadingState'
|
||||
import { CodexUsageDailyChart } from './CodexUsageDailyChart'
|
||||
import { ShareUsageButton } from './ShareUsageButton'
|
||||
import { StatCard } from './StatCard'
|
||||
|
||||
const RANGE_OPTIONS: CodexUsageRange[] = ['7d', '30d', '90d', 'all']
|
||||
|
|
@ -142,6 +143,9 @@ export function CodexUsagePane(): React.JSX.Element {
|
|||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 self-start">
|
||||
{summary && daily.length > 0 && (
|
||||
<ShareUsageButton provider="codex" summary={summary} daily={daily} range={range} />
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
|
|
|
|||
143
src/renderer/src/components/stats/ShareUsageButton.tsx
Normal file
143
src/renderer/src/components/stats/ShareUsageButton.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useCallback, useRef, useState } from 'react'
|
||||
import { toPng } from 'html-to-image'
|
||||
import { Check, Copy, Share2 } from 'lucide-react'
|
||||
import { Button } from '../ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../ui/dialog'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip'
|
||||
import { ShareUsageCard, type ShareUsageCardProps } from './ShareUsageCard'
|
||||
|
||||
type ShareUsageButtonProps = ShareUsageCardProps
|
||||
|
||||
function XIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg width={16} height={16} viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ShareUsageButton(props: ShareUsageButtonProps): React.JSX.Element {
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [capturing, setCapturing] = useState(false)
|
||||
|
||||
const captureToClipboard = useCallback(async () => {
|
||||
if (!cardRef.current || capturing) {
|
||||
return
|
||||
}
|
||||
setCapturing(true)
|
||||
try {
|
||||
const dataUrl = await toPng(cardRef.current, {
|
||||
pixelRatio: 2,
|
||||
backgroundColor: undefined
|
||||
})
|
||||
await window.api.ui.writeClipboardImage(dataUrl)
|
||||
return true
|
||||
} finally {
|
||||
setCapturing(false)
|
||||
}
|
||||
}, [capturing])
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
const ok = await captureToClipboard()
|
||||
if (ok) {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}, [captureToClipboard])
|
||||
|
||||
const handleShareToX = useCallback(async () => {
|
||||
const { provider, summary, range } = props
|
||||
const providerName = provider === 'claude' ? 'Claude' : 'Codex'
|
||||
const rangeLabel =
|
||||
range === '7d'
|
||||
? 'last 7 days'
|
||||
: range === '30d'
|
||||
? 'last 30 days'
|
||||
: range === '90d'
|
||||
? 'last 90 days'
|
||||
: 'all-time'
|
||||
|
||||
const totalTokens =
|
||||
provider === 'claude'
|
||||
? summary.inputTokens + summary.outputTokens
|
||||
: (summary as unknown as { totalTokens: number }).totalTokens
|
||||
|
||||
const cost = summary.estimatedCostUsd
|
||||
const costStr =
|
||||
cost === null ? 'n/a' : cost < 0.01 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`
|
||||
|
||||
const fmtTokens = (v: number): string => {
|
||||
if (v >= 1_000_000) {
|
||||
return `${(v / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (v >= 1_000) {
|
||||
return `${(v / 1_000).toFixed(1)}k`
|
||||
}
|
||||
return v.toLocaleString()
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`My ${rangeLabel} ${providerName} usage via @orca_build`,
|
||||
'',
|
||||
`${fmtTokens(totalTokens)} tokens · ${costStr} est. cost`,
|
||||
'',
|
||||
'github.com/stablyai/orca'
|
||||
]
|
||||
const url = `https://x.com/intent/post?text=${encodeURIComponent(lines.join('\n'))}`
|
||||
await window.api.shell.openUrl(url)
|
||||
}, [props])
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon-xs" aria-label="Share usage">
|
||||
<Share2 className="size-3.5" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
Share
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DialogContent className="max-w-fit" showCloseButton>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Share usage</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col items-center gap-3 py-2">
|
||||
<ShareUsageCard ref={cardRef} {...props} />
|
||||
<div className="flex w-full max-w-[480px] gap-2">
|
||||
<Button onClick={() => void handleCopy()} disabled={capturing} className="flex-1">
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="mr-2 size-4" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="mr-2 size-4" />
|
||||
Copy image
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleShareToX()}
|
||||
disabled={capturing}
|
||||
className="flex-1"
|
||||
>
|
||||
<span className="mr-2">
|
||||
<XIcon />
|
||||
</span>
|
||||
Share on X
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
359
src/renderer/src/components/stats/ShareUsageCard.tsx
Normal file
359
src/renderer/src/components/stats/ShareUsageCard.tsx
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import { forwardRef } from 'react'
|
||||
import type { ClaudeUsageSummary } from '../../../../shared/claude-usage-types'
|
||||
import type { CodexUsageSummary } from '../../../../shared/codex-usage-types'
|
||||
import {
|
||||
BackgroundGlows,
|
||||
CardFooter,
|
||||
formatCost,
|
||||
formatDateRange,
|
||||
formatTokens,
|
||||
getDailySegments,
|
||||
getDailyTotal,
|
||||
getLegendItems,
|
||||
OrcaLogo,
|
||||
RANGE_LABELS
|
||||
} from './share-card-utils'
|
||||
import type { ClaudeShareData, CodexShareData } from './share-card-utils'
|
||||
|
||||
export type ShareUsageCardProps = (ClaudeShareData | CodexShareData) & {
|
||||
range: string
|
||||
}
|
||||
|
||||
// Why: html-to-image uses SVG foreignObject which handles modern CSS fine,
|
||||
// but inline styles are kept for portability and to avoid Tailwind class stripping.
|
||||
export const ShareUsageCard = forwardRef<HTMLDivElement, ShareUsageCardProps>(
|
||||
function ShareUsageCard(props, ref) {
|
||||
const { provider, summary, daily, range } = props
|
||||
const slicedDaily = daily.slice(-10)
|
||||
|
||||
const totalTokens =
|
||||
provider === 'claude'
|
||||
? summary.inputTokens + summary.outputTokens
|
||||
: (summary as CodexUsageSummary).totalTokens
|
||||
|
||||
const topModel =
|
||||
provider === 'claude'
|
||||
? ((summary as ClaudeUsageSummary).topModel ?? 'n/a')
|
||||
: ((summary as CodexUsageSummary).topModel ?? 'n/a')
|
||||
|
||||
const sessions =
|
||||
provider === 'claude'
|
||||
? (summary as ClaudeUsageSummary).sessions
|
||||
: (summary as CodexUsageSummary).sessions
|
||||
|
||||
const turnsOrEvents =
|
||||
provider === 'claude'
|
||||
? { label: 'turns', count: (summary as ClaudeUsageSummary).turns }
|
||||
: { label: 'events', count: (summary as CodexUsageSummary).events }
|
||||
|
||||
const providerLabel = provider === 'claude' ? 'Claude' : 'Codex'
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
width: 480,
|
||||
padding: '28px 28px 24px',
|
||||
background: 'linear-gradient(145deg, #111111 0%, #0a0a0a 50%, #0d0d1a 100%)',
|
||||
borderRadius: 16,
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
color: '#fafafa',
|
||||
fontFamily: "'Helvetica Neue', Arial, sans-serif",
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
position: 'relative',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
<BackgroundGlows />
|
||||
<CardHeader providerLabel={providerLabel} range={range} />
|
||||
|
||||
<div
|
||||
style={{ fontSize: 11, color: '#555', position: 'relative', zIndex: 1, marginBottom: 16 }}
|
||||
>
|
||||
{formatDateRange(range)}
|
||||
</div>
|
||||
|
||||
<StatsGrid summary={summary} totalTokens={totalTokens} topModel={topModel} />
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<ChartHeader sessions={sessions} turnsOrEvents={turnsOrEvents} />
|
||||
<DailyChart slicedDaily={slicedDaily} />
|
||||
<DayLabels slicedDaily={slicedDaily} />
|
||||
<Legend provider={provider} />
|
||||
</div>
|
||||
|
||||
<CardFooter summary={summary} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
function CardHeader(props: { providerLabel: string; range: string }): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'table',
|
||||
width: '100%',
|
||||
marginBottom: 6,
|
||||
position: 'relative',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'table-cell', verticalAlign: 'middle' }}>
|
||||
<div style={{ display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<OrcaLogo />
|
||||
</div>
|
||||
<div style={{ display: 'inline-block', verticalAlign: 'middle', marginLeft: 10 }}>
|
||||
<div style={{ fontSize: 14, fontWeight: 600, color: '#fafafa', lineHeight: 1.2 }}>
|
||||
Orca IDE
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#555', letterSpacing: 0.3 }}>
|
||||
{props.providerLabel} Usage
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'table-cell', verticalAlign: 'middle', textAlign: 'right' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: '#a1a1a1',
|
||||
background: 'rgba(255, 255, 255, 0.06)',
|
||||
padding: '3px 8px',
|
||||
borderRadius: 6,
|
||||
letterSpacing: 0.3
|
||||
}}
|
||||
>
|
||||
{RANGE_LABELS[props.range] ?? props.range}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatsGrid(props: {
|
||||
summary: { estimatedCostUsd: number | null }
|
||||
totalTokens: number
|
||||
topModel: string
|
||||
}): React.JSX.Element {
|
||||
const cards = [
|
||||
{
|
||||
value: formatCost(props.summary.estimatedCostUsd ?? null),
|
||||
label: 'Est. cost',
|
||||
bg: 'rgba(20, 71, 230, 0.1)',
|
||||
border: '1px solid rgba(20, 71, 230, 0.2)',
|
||||
valueColor: '#93b4ff',
|
||||
valueFontSize: 16
|
||||
},
|
||||
{
|
||||
value: formatTokens(props.totalTokens),
|
||||
label: 'Total tokens',
|
||||
bg: 'rgba(255, 255, 255, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
valueColor: '#fafafa',
|
||||
valueFontSize: 16
|
||||
},
|
||||
{
|
||||
value: props.topModel,
|
||||
label: 'Top model',
|
||||
bg: 'rgba(255, 255, 255, 0.04)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.06)',
|
||||
valueColor: '#fafafa',
|
||||
valueFontSize: 14
|
||||
}
|
||||
]
|
||||
return (
|
||||
<div style={{ position: 'relative', zIndex: 1, marginBottom: 20 }}>
|
||||
{cards.map((card, i) => (
|
||||
<div
|
||||
key={card.label}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'top',
|
||||
width: 'calc(33.33% - 6px)',
|
||||
marginLeft: i > 0 ? 8 : 0,
|
||||
background: card.bg,
|
||||
border: card.border,
|
||||
borderRadius: 10,
|
||||
padding: '10px 12px',
|
||||
height: 52,
|
||||
overflow: 'hidden',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: card.valueFontSize,
|
||||
fontWeight: 600,
|
||||
color: card.valueColor,
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis'
|
||||
}}
|
||||
>
|
||||
{card.value}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: '#666', marginTop: 2, letterSpacing: 0.2 }}>
|
||||
{card.label}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ChartHeader(props: {
|
||||
sessions: number
|
||||
turnsOrEvents: { label: string; count: number }
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div style={{ display: 'table', width: '100%', marginBottom: 10 }}>
|
||||
<div style={{ display: 'table-cell', verticalAlign: 'bottom' }}>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 500,
|
||||
color: '#555',
|
||||
letterSpacing: 0.3,
|
||||
textTransform: 'uppercase' as const
|
||||
}}
|
||||
>
|
||||
Daily tokens
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'table-cell', verticalAlign: 'bottom', textAlign: 'right' }}>
|
||||
<span style={{ fontSize: 10, color: '#444' }}>
|
||||
{props.sessions} sessions · {props.turnsOrEvents.count} {props.turnsOrEvents.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DailyChart(props: {
|
||||
slicedDaily: Parameters<typeof getDailySegments>[0][]
|
||||
}): React.JSX.Element {
|
||||
const CHART_H = 120
|
||||
const maxSegSum = Math.max(
|
||||
1,
|
||||
...props.slicedDaily.map((entry) => {
|
||||
const segs = getDailySegments(entry)
|
||||
return segs.reduce((sum, s) => sum + s.value, 0)
|
||||
})
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
tableLayout: 'fixed',
|
||||
marginBottom: 6
|
||||
}}
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
{props.slicedDaily.map((entry) => (
|
||||
<td
|
||||
key={entry.day}
|
||||
style={{ textAlign: 'center', padding: '0 3px', fontSize: 8, color: '#444' }}
|
||||
>
|
||||
{formatTokens(getDailyTotal(entry))}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ height: CHART_H, overflow: 'hidden', marginBottom: 8 }}>
|
||||
<table
|
||||
style={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
tableLayout: 'fixed',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<tbody>
|
||||
<tr>
|
||||
{props.slicedDaily.map((entry) => {
|
||||
const segments = getDailySegments(entry)
|
||||
return (
|
||||
<td
|
||||
key={entry.day}
|
||||
style={{ verticalAlign: 'bottom', textAlign: 'center', padding: '0 3px' }}
|
||||
>
|
||||
{segments.map((seg) =>
|
||||
seg.value > 0 ? (
|
||||
<div
|
||||
key={seg.key}
|
||||
style={{
|
||||
height: Math.max(1, Math.round((seg.value / maxSegSum) * CHART_H)),
|
||||
background: seg.color,
|
||||
marginLeft: '15%',
|
||||
marginRight: '15%'
|
||||
}}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function DayLabels(props: { slicedDaily: { day: string }[] }): React.JSX.Element {
|
||||
return (
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', tableLayout: 'fixed' }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
{props.slicedDaily.map((entry) => (
|
||||
<td
|
||||
key={entry.day}
|
||||
style={{ textAlign: 'center', fontSize: 9, color: '#555', padding: '0 3px' }}
|
||||
>
|
||||
{entry.day.slice(5)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
|
||||
function Legend(props: { provider: 'claude' | 'codex' }): React.JSX.Element {
|
||||
return (
|
||||
<div style={{ marginTop: 10 }}>
|
||||
{getLegendItems(props.provider).map((item, i) => (
|
||||
<span
|
||||
key={item.label}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginRight: i < 3 ? 12 : 0,
|
||||
fontSize: 9,
|
||||
color: '#555',
|
||||
lineHeight: '14px'
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: '50%',
|
||||
background: item.color,
|
||||
verticalAlign: 'middle',
|
||||
marginRight: 5
|
||||
}}
|
||||
/>
|
||||
<span style={{ verticalAlign: 'middle' }}>{item.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
209
src/renderer/src/components/stats/share-card-utils.tsx
Normal file
209
src/renderer/src/components/stats/share-card-utils.tsx
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import type {
|
||||
ClaudeUsageDailyPoint,
|
||||
ClaudeUsageSummary
|
||||
} from '../../../../shared/claude-usage-types'
|
||||
import type { CodexUsageDailyPoint, CodexUsageSummary } from '../../../../shared/codex-usage-types'
|
||||
|
||||
export type ClaudeShareData = {
|
||||
provider: 'claude'
|
||||
summary: ClaudeUsageSummary
|
||||
daily: ClaudeUsageDailyPoint[]
|
||||
}
|
||||
|
||||
export type CodexShareData = {
|
||||
provider: 'codex'
|
||||
summary: CodexUsageSummary
|
||||
daily: CodexUsageDailyPoint[]
|
||||
}
|
||||
|
||||
export function formatTokens(value: number): string {
|
||||
if (value >= 1_000_000) {
|
||||
return `${(value / 1_000_000).toFixed(1)}M`
|
||||
}
|
||||
if (value >= 1_000) {
|
||||
return `${(value / 1_000).toFixed(1)}k`
|
||||
}
|
||||
return value.toLocaleString()
|
||||
}
|
||||
|
||||
export function formatCost(value: number | null): string {
|
||||
if (value === null) {
|
||||
return 'n/a'
|
||||
}
|
||||
return value < 0.01 ? `$${value.toFixed(4)}` : `$${value.toFixed(2)}`
|
||||
}
|
||||
|
||||
export function formatDateRange(range: string): string {
|
||||
const now = new Date()
|
||||
const end = now.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
if (range === 'all') {
|
||||
return `Through ${end}`
|
||||
}
|
||||
const days = parseInt(range)
|
||||
if (Number.isNaN(days)) {
|
||||
return end
|
||||
}
|
||||
const start = new Date(now.getTime() - days * 86_400_000)
|
||||
const startStr = start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
|
||||
return `${startStr} – ${end}`
|
||||
}
|
||||
|
||||
export const RANGE_LABELS: Record<string, string> = {
|
||||
'7d': 'Last 7 days',
|
||||
'30d': 'Last 30 days',
|
||||
'90d': 'Last 90 days',
|
||||
all: 'All time'
|
||||
}
|
||||
|
||||
export function getDailyTotal(entry: ClaudeUsageDailyPoint | CodexUsageDailyPoint): number {
|
||||
if ('cacheReadTokens' in entry) {
|
||||
return entry.inputTokens + entry.outputTokens + entry.cacheReadTokens + entry.cacheWriteTokens
|
||||
}
|
||||
return entry.totalTokens
|
||||
}
|
||||
|
||||
export type DailySegment = { key: string; value: number; color: string }
|
||||
|
||||
export function getDailySegments(
|
||||
entry: ClaudeUsageDailyPoint | CodexUsageDailyPoint
|
||||
): DailySegment[] {
|
||||
// Why: segment order matches the original charts exactly (top-to-bottom).
|
||||
// Segments render as stacked block divs in a table cell with vertical-align: bottom.
|
||||
if ('cacheReadTokens' in entry) {
|
||||
return [
|
||||
{ key: 'cache-write', value: entry.cacheWriteTokens, color: 'rgba(217, 70, 239, 0.7)' },
|
||||
{ key: 'cache-read', value: entry.cacheReadTokens, color: 'rgba(251, 191, 36, 0.7)' },
|
||||
{ key: 'output', value: entry.outputTokens, color: 'rgba(52, 211, 153, 0.8)' },
|
||||
{ key: 'input', value: entry.inputTokens, color: 'rgba(56, 189, 248, 0.8)' }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ key: 'input', value: entry.inputTokens, color: 'rgba(56, 189, 248, 0.8)' },
|
||||
{ key: 'output', value: entry.outputTokens, color: 'rgba(52, 211, 153, 0.8)' },
|
||||
{ key: 'cached', value: entry.cachedInputTokens, color: 'rgba(251, 191, 36, 0.7)' },
|
||||
{ key: 'reasoning', value: entry.reasoningOutputTokens, color: 'rgba(217, 70, 239, 0.7)' }
|
||||
]
|
||||
}
|
||||
|
||||
export function getLegendItems(provider: 'claude' | 'codex') {
|
||||
if (provider === 'claude') {
|
||||
return [
|
||||
{ label: 'Input', color: 'rgba(56, 189, 248, 0.8)' },
|
||||
{ label: 'Output', color: 'rgba(52, 211, 153, 0.8)' },
|
||||
{ label: 'Cache read', color: 'rgba(251, 191, 36, 0.7)' },
|
||||
{ label: 'Cache write', color: 'rgba(217, 70, 239, 0.7)' }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ label: 'Input', color: 'rgba(56, 189, 248, 0.8)' },
|
||||
{ label: 'Output', color: 'rgba(52, 211, 153, 0.8)' },
|
||||
{ label: 'Cached input', color: 'rgba(251, 191, 36, 0.7)' },
|
||||
{ label: 'Reasoning', color: 'rgba(217, 70, 239, 0.7)' }
|
||||
]
|
||||
}
|
||||
|
||||
export function OrcaLogo(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={26}
|
||||
height={26}
|
||||
viewBox="0 0 318.60232 202.66667"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{ opacity: 0.9, verticalAlign: 'middle' }}
|
||||
>
|
||||
<g style={{ display: 'inline' }} transform="translate(-6.6666669,-70.666669)">
|
||||
<path
|
||||
style={{ display: 'inline', fill: '#ffffff' }}
|
||||
d="m 177.81311,248.33334 c 23.82304,-41.29793 40.54045,-66.84626 49.51207,-75.66667 6.81685,-6.70196 10.07373,-8.7374 20.07265,-12.54475 34.57822,-13.16655 61.04674,-26.78733 72.37222,-37.24295 9.62924,-8.88966 9.34286,-9.01142 -23.43671,-9.964 -35.71756,-1.03796 -43.72989,0.42119 -62.17546,11.323 -16.72118,9.88265 -34.20103,30.11225 -42.74704,49.47157 -2.57353,5.82985 -14.81294,44.3056 -27.96399,87.90747 -2.86036,9.48343 -3.02466,11.71633 -0.86213,11.71633 0.44382,0 7.29659,-11.25 15.22839,-25 z m -65.14644,-8.32267 C 120,239.3326 130.5,237.50979 136,235.95998 c 5.5,-1.5498 12.25,-3.13783 15,-3.52895 2.75,-0.39111 5,-0.95485 5,-1.25275 0,-0.29789 2.15135,-7.58487 4.78078,-16.19328 8.49209,-27.80201 12.21334,-40.41629 21.13747,-71.65166 4.81891,-16.86667 11.23502,-39.185 14.25802,-49.596301 5.12803,-17.66103 5.74763,-23.07037 2.64253,-23.07037 -1.84887,0 -4.07048,6.908293 -16.72243,52.000001 -21.78975,77.65896 -20.80806,74.74393 -26.84794,79.72251 -7.5925,6.25838 -25.03916,14.82524 -36.10856,17.73044 -17.0947,4.48656 -33.410599,3.86724 -53.116765,-2.01622 -18.569242,-5.54403 -23.142662,-5.80284 -33.639754,-1.9037 -5.875424,2.18242 -9.864152,5.04363 -16.716684,11.99127 -4.95,5.0187 -9.0000001,10.02884 -9.0000001,11.13364 0,1.75174 5.9276921,2.00299 46.3333351,1.96383 25.483334,-0.0247 52.333338,-0.59969 59.666668,-1.27777 z M 252.69513,104.63708 c 12.18267,-3.48651 15.77304,-7.895503 9.63821,-11.835773 -10.19296,-6.546726 -36.19849,-1.77301 -41.19436,7.561863 -1.2556,2.3461 -0.98698,3.2037 1.68353,5.375 2.69471,2.19098 4.59991,2.47691 12.53928,1.88189 5.14899,-0.3859 12.94899,-1.72824 17.33334,-2.98298 z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function BackgroundGlows(): React.JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-60%',
|
||||
right: '-20%',
|
||||
width: 300,
|
||||
height: 300,
|
||||
background: 'radial-gradient(circle, rgba(20, 71, 230, 0.08) 0%, transparent 70%)',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-40%',
|
||||
left: '-10%',
|
||||
width: 250,
|
||||
height: 250,
|
||||
background: 'radial-gradient(circle, rgba(139, 92, 246, 0.05) 0%, transparent 70%)',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardFooter(props: {
|
||||
summary: { inputTokens: number; outputTokens: number }
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'table',
|
||||
width: '100%',
|
||||
marginTop: 16,
|
||||
paddingTop: 12,
|
||||
borderTop: '1px solid rgba(255, 255, 255, 0.05)',
|
||||
position: 'relative',
|
||||
zIndex: 1
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'table-cell', verticalAlign: 'middle' }}>
|
||||
<span style={{ fontSize: 12, color: '#888' }}>
|
||||
<strong style={{ color: '#ccc' }}>{formatTokens(props.summary.inputTokens)}</strong> input
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: '#888', marginLeft: 16 }}>
|
||||
<strong style={{ color: '#ccc' }}>{formatTokens(props.summary.outputTokens)}</strong>{' '}
|
||||
output
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'table-cell', verticalAlign: 'middle', textAlign: 'right' }}>
|
||||
<span style={{ display: 'inline-block', verticalAlign: 'middle' }}>
|
||||
<GitHubIcon />
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#888',
|
||||
letterSpacing: 0.2,
|
||||
verticalAlign: 'middle',
|
||||
marginLeft: 5
|
||||
}}
|
||||
>
|
||||
github.com/stablyai/orca
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function GitHubIcon(): React.JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={13}
|
||||
height={13}
|
||||
viewBox="0 0 16 16"
|
||||
fill="#888"
|
||||
style={{ opacity: 0.6, verticalAlign: 'middle' }}
|
||||
>
|
||||
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue