wip: stats
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#4B4646"/>
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#B7B1B1"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#4B4646"/>
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#B7B1B1"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#4B4646"/>
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#B7B1B1"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#4B4646"/>
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#B7B1B1"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#4B4646"/>
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#F1ECEC"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#4B4646"/>
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#F1ECEC"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#4B4646"/>
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#F1ECEC"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#4B4646"/>
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#F1ECEC"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg width="234" height="42" viewBox="0 0 234 42" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 30H6V18H18V30Z" fill="#CFCECD"/>
|
||||
<path d="M18 12H6V30H18V12ZM24 36H0V6H24V36Z" fill="#656363"/>
|
||||
<path d="M48 30H36V18H48V30Z" fill="#CFCECD"/>
|
||||
<path d="M36 30H48V12H36V30ZM54 36H36V42H30V6H54V36Z" fill="#656363"/>
|
||||
<path d="M84 24V30H66V24H84Z" fill="#CFCECD"/>
|
||||
<path d="M84 24H66V30H84V36H60V6H84V24ZM66 18H78V12H66V18Z" fill="#656363"/>
|
||||
<path d="M108 36H96V18H108V36Z" fill="#CFCECD"/>
|
||||
<path d="M108 12H96V36H90V6H108V12ZM114 36H108V12H114V36Z" fill="#656363"/>
|
||||
<path d="M144 30H126V18H144V30Z" fill="#CFCECD"/>
|
||||
<path d="M144 12H126V30H144V36H120V6H144V12Z" fill="#211E1E"/>
|
||||
<path d="M168 30H156V18H168V30Z" fill="#CFCECD"/>
|
||||
<path d="M168 12H156V30H168V12ZM174 36H150V6H174V36Z" fill="#211E1E"/>
|
||||
<path d="M198 30H186V18H198V30Z" fill="#CFCECD"/>
|
||||
<path d="M198 12H186V30H198V12ZM204 36H180V6H198V0H204V36Z" fill="#211E1E"/>
|
||||
<path d="M234 24V30H216V24H234Z" fill="#CFCECD"/>
|
||||
<path d="M216 12V18H228V12H216ZM234 24H216V30H234V36H210V6H234V24Z" fill="#211E1E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -21,6 +21,8 @@
|
||||
--stats-accent-text: #6c7dff;
|
||||
--stats-bar-idle: #d4d4d4;
|
||||
--stats-dot: #d4d4d4;
|
||||
--stats-page-padding: 5rem;
|
||||
--stats-section-padding: 6rem;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -52,11 +54,10 @@
|
||||
justify-content: space-between;
|
||||
height: 80px;
|
||||
min-height: 80px;
|
||||
padding: 24px 80px;
|
||||
padding: 24px var(--stats-page-padding);
|
||||
color: var(--stats-muted);
|
||||
background: color-mix(in srgb, var(--stats-bg) 94%, transparent);
|
||||
background: var(--stats-bg);
|
||||
border-bottom: 1px solid var(--stats-line);
|
||||
backdrop-filter: blur(16px);
|
||||
font-family:
|
||||
"IBM Plex Mono",
|
||||
var(--font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||
@@ -74,9 +75,18 @@
|
||||
text-underline-offset: 4px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="wordmark"] {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.04em;
|
||||
[data-page="stats"] [data-slot="brand"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="brand"] img {
|
||||
width: auto;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="logo dark"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="nav-desktop"] ul {
|
||||
@@ -88,6 +98,27 @@
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="nav-desktop"] a span {
|
||||
color: var(--stats-faint);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 16px 8px 10px;
|
||||
border-radius: 4px;
|
||||
background: var(--color-background-strong);
|
||||
color: var(--color-text-inverted);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="nav-desktop"] [data-slot="cta-button"]:hover {
|
||||
background: var(--color-background-strong-hover);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="footer"] {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--stats-line);
|
||||
@@ -157,7 +188,7 @@
|
||||
[data-page="stats"] [data-section="chart"],
|
||||
[data-page="stats"] [data-section="newsletter"] {
|
||||
border-bottom: 1px solid var(--stats-line);
|
||||
padding: 96px 80px;
|
||||
padding: var(--stats-section-padding) var(--stats-page-padding);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="hero"] {
|
||||
@@ -237,7 +268,7 @@
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="chart"] {
|
||||
min-height: 760px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="section-header"] {
|
||||
@@ -943,21 +974,122 @@
|
||||
|
||||
[data-page="stats"] [data-component="country-map"] circle {
|
||||
fill: var(--stats-accent);
|
||||
fill-opacity: 0.2;
|
||||
fill-opacity: 0.14;
|
||||
stroke: var(--stats-accent);
|
||||
stroke-width: 2;
|
||||
stroke-width: 1.5;
|
||||
transition:
|
||||
fill-opacity 120ms ease,
|
||||
stroke-width 120ms ease;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="country-map"] g[role="button"] {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="country-map"] g[role="button"][data-active="true"] circle {
|
||||
fill-opacity: 0.28;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="country-map"] text {
|
||||
fill: var(--stats-muted);
|
||||
fill: var(--stats-text);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="map-tooltip"] {
|
||||
top: 90px;
|
||||
left: 220px;
|
||||
top: 24px;
|
||||
left: 24px;
|
||||
right: auto;
|
||||
min-width: 120px;
|
||||
min-width: 168px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="map-tooltip"] span,
|
||||
[data-page="stats"] [data-component="map-tooltip"] em {
|
||||
color: var(--stats-muted);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="map-tooltip"] p {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: var(--stats-text);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="country-list"] {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin: 20px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="country-list"] button {
|
||||
display: grid;
|
||||
grid-template-columns: 24px minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid var(--stats-line);
|
||||
border-radius: 0;
|
||||
background: var(--stats-layer);
|
||||
color: var(--stats-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="country-list"] button[data-active="true"] {
|
||||
border-color: var(--stats-line-strong);
|
||||
background: var(--stats-layer-2);
|
||||
color: var(--stats-muted);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="country-list"] span,
|
||||
[data-page="stats"] [data-component="country-list"] em {
|
||||
color: var(--stats-faint);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="country-list"] strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--stats-text);
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="country-list"] b {
|
||||
color: var(--stats-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="country-footer"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="country-footer"] p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--stats-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="country-footer"] span {
|
||||
color: var(--stats-faint);
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-section="newsletter"] {
|
||||
@@ -1035,6 +1167,14 @@
|
||||
0 1px 2px -1px #00000052,
|
||||
0 2px 4px #0000003d;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="logo light"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="logo dark"] {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 74rem) {
|
||||
@@ -1044,6 +1184,11 @@
|
||||
}
|
||||
|
||||
@media (max-width: 58rem) {
|
||||
[data-page="stats"] {
|
||||
--stats-page-padding: 24px;
|
||||
--stats-section-padding: 4rem;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="top"] {
|
||||
padding-left: 24px;
|
||||
padding-right: 24px;
|
||||
@@ -1129,13 +1274,23 @@
|
||||
grid-template-rows: none;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="country-list"] {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-slot="market-footer"] {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="chart-tooltip"] {
|
||||
[data-page="stats"] [data-slot="country-footer"] {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
[data-page="stats"] [data-component="chart-tooltip"],
|
||||
[data-page="stats"] [data-component="map-tooltip"] {
|
||||
position: static;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import "./index.css"
|
||||
import { Meta, Title } from "@solidjs/meta"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import {
|
||||
type CountryEntry,
|
||||
getStatsHomeData,
|
||||
type LeaderboardEntry,
|
||||
type MarketDay,
|
||||
@@ -15,12 +16,28 @@ import { createAsync, query } from "@solidjs/router"
|
||||
import { scaleBand, scaleLinear } from "d3-scale"
|
||||
import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
|
||||
import { getRequestEvent } from "solid-js/web"
|
||||
import logoDark from "../asset/logo-ornate-dark.svg"
|
||||
import logoLight from "../asset/logo-ornate-light.svg"
|
||||
|
||||
const products = ["All Users", "Zen", "Go", "Enterprise"] as const
|
||||
const tokenProducts = ["Zen", "Go", "Enterprise"] as const
|
||||
const ranges = ["1D", "1W", "1M", "3M", "YTD", "ALL"] as const
|
||||
const usageColors = ["#ff5d64", "#ff8a00", "#8bef00", "#12c8b3", "#18c7dc", "#6c7dff", "#9d73f7"]
|
||||
const marketColors = ["#ed6aff", "#a684ff", "#7c86ff", "#51a2ff", "#00d3f2", "#00d5be", "#00bc7d", "#9ae600", "#ffb900"]
|
||||
const countryPositions = [
|
||||
{ x: 112, y: 96 },
|
||||
{ x: 284, y: 144 },
|
||||
{ x: 472, y: 92 },
|
||||
{ x: 642, y: 154 },
|
||||
{ x: 800, y: 96 },
|
||||
{ x: 172, y: 234 },
|
||||
{ x: 362, y: 250 },
|
||||
{ x: 552, y: 236 },
|
||||
{ x: 744, y: 252 },
|
||||
{ x: 48, y: 184 },
|
||||
{ x: 892, y: 198 },
|
||||
{ x: 456, y: 176 },
|
||||
] as const
|
||||
|
||||
type UsageProduct = (typeof products)[number]
|
||||
type TokenProduct = (typeof tokenProducts)[number]
|
||||
@@ -54,15 +71,7 @@ export default function StatsHome() {
|
||||
<MarketShareSection data={stats().market} />
|
||||
<TokenCostSection data={stats().tokenCost} />
|
||||
<SessionCostSection data={stats().sessionCost} />
|
||||
<ChartSection
|
||||
title="Token by Country"
|
||||
description="Country-level token data is collected in geo_stat; this chart is not wired yet."
|
||||
>
|
||||
<EmptyState
|
||||
title="No country chart"
|
||||
description="Wire geo_stat into the stats payload to power this chart."
|
||||
/>
|
||||
</ChartSection>
|
||||
<CountrySection data={stats().country} />
|
||||
<Newsletter />
|
||||
</>
|
||||
)}
|
||||
@@ -741,6 +750,126 @@ function formatSessionCost(value: number) {
|
||||
return `$${value.toFixed(4)}`
|
||||
}
|
||||
|
||||
function CountrySection(props: { data: StatsHomeData["country"] }) {
|
||||
const [range, setRange] = createSignal<UsageRange>("1W")
|
||||
const data = createMemo(() => props.data[range()])
|
||||
|
||||
return (
|
||||
<ChartSection title="Token by Country" description="Country-level token totals from geo_stat.">
|
||||
<Show
|
||||
when={data().length > 0}
|
||||
fallback={<EmptyState title="No country data" description="No geo_stat rows matched this range." />}
|
||||
>
|
||||
<CountryChart data={data()} />
|
||||
</Show>
|
||||
<div data-slot="country-footer">
|
||||
<p>
|
||||
<span>[*]</span>
|
||||
<strong>Top countries by tokens</strong>
|
||||
</p>
|
||||
<FilterPills items={ranges} selected={range()} label="Date range" variant="range" onSelect={setRange} />
|
||||
</div>
|
||||
</ChartSection>
|
||||
)
|
||||
}
|
||||
|
||||
function CountryChart(props: { data: CountryEntry[] }) {
|
||||
const [activeIndex, setActiveIndex] = createSignal(0)
|
||||
const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(props.data.length - 1, 0)))
|
||||
const active = createMemo(() => props.data[selectedIndex()])
|
||||
const max = createMemo(() => Math.max(0.0001, ...props.data.map((item) => item.tokens)))
|
||||
|
||||
return (
|
||||
<div data-component="country-map">
|
||||
<svg viewBox="0 0 920 320" role="img" aria-label="Country token share bubble chart">
|
||||
<For each={props.data.slice(0, countryPositions.length)}>
|
||||
{(item, index) => {
|
||||
const position = countryPositions[index()]
|
||||
const radius = 18 + Math.sqrt(item.tokens / max()) * 58
|
||||
return (
|
||||
<g
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${formatCountry(item.country)} ${formatTokens(item.tokens)}`}
|
||||
data-active={selectedIndex() === index() ? "true" : undefined}
|
||||
onPointerEnter={() => setActiveIndex(index())}
|
||||
onClick={() => setActiveIndex(index())}
|
||||
onFocus={() => setActiveIndex(index())}
|
||||
>
|
||||
<circle cx={position.x} cy={position.y} r={radius} />
|
||||
<text x={position.x} y={position.y + 4} text-anchor="middle">
|
||||
{item.country}
|
||||
</text>
|
||||
</g>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</svg>
|
||||
<Show when={active()}>
|
||||
{(item) => (
|
||||
<div data-component="map-tooltip">
|
||||
<strong>{formatCountry(item().country)}</strong>
|
||||
<span>{item().continent || "Unknown region"}</span>
|
||||
<p>
|
||||
<b>{formatTokens(item().tokens)}</b>
|
||||
<em>{item().share.toFixed(1)}%</em>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<CountryList data={props.data.slice(0, 8)} activeIndex={selectedIndex()} onActiveIndexChange={setActiveIndex} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CountryList(props: {
|
||||
data: CountryEntry[]
|
||||
activeIndex: number
|
||||
onActiveIndexChange: (index: number) => void
|
||||
}) {
|
||||
return (
|
||||
<ol data-component="country-list">
|
||||
<For each={props.data}>
|
||||
{(item, index) => (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
data-active={props.activeIndex === index() ? "true" : undefined}
|
||||
onClick={() => props.onActiveIndexChange(index())}
|
||||
onPointerEnter={() => props.onActiveIndexChange(index())}
|
||||
>
|
||||
<span>{String(item.rank).padStart(2, "0")}</span>
|
||||
<strong>{formatCountry(item.country)}</strong>
|
||||
<em>{formatTokens(item.tokens)}</em>
|
||||
<b>{item.share.toFixed(1)}%</b>
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
</For>
|
||||
</ol>
|
||||
)
|
||||
}
|
||||
|
||||
function formatCountry(country: string) {
|
||||
const known: Record<string, string> = {
|
||||
AU: "Australia",
|
||||
BR: "Brazil",
|
||||
CA: "Canada",
|
||||
CN: "China",
|
||||
DE: "Germany",
|
||||
FR: "France",
|
||||
GB: "United Kingdom",
|
||||
IN: "India",
|
||||
JP: "Japan",
|
||||
KR: "South Korea",
|
||||
NL: "Netherlands",
|
||||
SG: "Singapore",
|
||||
US: "United States",
|
||||
ZZ: "Unknown",
|
||||
}
|
||||
return known[country] ?? country
|
||||
}
|
||||
|
||||
function Newsletter() {
|
||||
return (
|
||||
<section data-section="newsletter">
|
||||
@@ -759,13 +888,14 @@ function Newsletter() {
|
||||
function Header() {
|
||||
return (
|
||||
<section data-component="top">
|
||||
<a data-slot="wordmark" href="/">
|
||||
opencode
|
||||
<a data-slot="brand" href="https://opencode.ai/" aria-label="OpenCode home">
|
||||
<img data-slot="logo light" src={logoLight} alt="OpenCode" width="234" height="42" />
|
||||
<img data-slot="logo dark" src={logoDark} alt="OpenCode" width="234" height="42" />
|
||||
</a>
|
||||
<nav data-component="nav-desktop" aria-label="Main navigation">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/sst/opencode" target="_blank">
|
||||
<a href="https://github.com/sst/opencode" target="_blank" rel="noreferrer">
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
@@ -773,7 +903,26 @@ function Header() {
|
||||
<a href="https://opencode.ai/docs">Docs</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/">Stats</a>
|
||||
<a href="https://opencode.ai/zen">Zen</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://opencode.ai/go">Go</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://opencode.ai/enterprise">Enterprise</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://opencode.ai/download" data-slot="cta-button">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="square"
|
||||
/>
|
||||
</svg>
|
||||
Download
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
@@ -785,7 +934,7 @@ function Footer() {
|
||||
return (
|
||||
<footer data-component="footer">
|
||||
<div data-slot="cell">
|
||||
<a href="https://github.com/sst/opencode" target="_blank">
|
||||
<a href="https://github.com/sst/opencode" target="_blank" rel="noreferrer">
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import { DatabaseError, DrizzleClient } from "../database"
|
||||
@@ -16,9 +16,18 @@ import {
|
||||
|
||||
export type GeoStatRow = typeof geoStat.$inferInsert
|
||||
export type GeoStatAggregate = StatBaseAggregate & { country: string; continent: string }
|
||||
export type GeoStatMetric = {
|
||||
periodStart: Date
|
||||
periodEnd: Date
|
||||
tier: string
|
||||
country: string
|
||||
continent: string
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export declare namespace GeoStatRepo {
|
||||
export interface Service {
|
||||
readonly listDaily: () => Effect.Effect<GeoStatMetric[], DatabaseError>
|
||||
readonly listByPeriod: (opts: {
|
||||
readonly grain: string
|
||||
readonly periodStart: Date
|
||||
@@ -37,6 +46,25 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
|
||||
Effect.gen(function* () {
|
||||
const db = yield* DrizzleClient
|
||||
|
||||
const listDaily = Effect.fn("GeoStatRepo.listDaily")(function* () {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
db
|
||||
.select({
|
||||
periodStart: geoStat.period_start,
|
||||
periodEnd: geoStat.period_end,
|
||||
tier: geoStat.tier,
|
||||
country: geoStat.country,
|
||||
continent: geoStat.continent,
|
||||
totalTokens: geoStat.total_tokens,
|
||||
})
|
||||
.from(geoStat)
|
||||
.where(and(eq(geoStat.grain, "day"), eq(geoStat.client, "all"), eq(geoStat.source, "all")))
|
||||
.orderBy(asc(geoStat.period_start)),
|
||||
catch: (cause) => DatabaseError.make({ cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const listByPeriod = Effect.fn("GeoStatRepo.listByPeriod")(function* (opts: {
|
||||
readonly grain: string
|
||||
readonly periodStart: Date
|
||||
@@ -112,7 +140,7 @@ export class GeoStatRepo extends Context.Service<GeoStatRepo, GeoStatRepo.Servic
|
||||
)
|
||||
})
|
||||
|
||||
return GeoStatRepo.of({ listByPeriod, upsert })
|
||||
return GeoStatRepo.of({ listDaily, listByPeriod, upsert })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Effect } from "effect"
|
||||
import { DatabaseError } from "../database"
|
||||
import { GeoStatRepo, type GeoStatMetric } from "./geo"
|
||||
import { ModelStatRepo, type ModelStatMetric } from "./model"
|
||||
import { ProviderStatRepo, type ProviderStatMetric } from "./provider"
|
||||
|
||||
export type UsageProduct = "All Users" | "Zen" | "Go" | "Enterprise"
|
||||
export type TokenProduct = "Zen" | "Go" | "Enterprise"
|
||||
@@ -10,6 +12,7 @@ export type MarketDay = { date: string; total: number; authors: { author: string
|
||||
export type LeaderboardEntry = { model: string; author: string; tokens: number; change: number; rank: number }
|
||||
export type TokenCostEntry = { model: string; total: number; input: number; output: number; cached: number }
|
||||
export type SessionCostEntry = { model: string; cost: number; tokens: number }
|
||||
export type CountryEntry = { country: string; continent: string; tokens: number; share: number; rank: number }
|
||||
export type StatsHomeData = {
|
||||
updatedAt: string | null
|
||||
usage: Record<UsageProduct, Record<UsageRange, UsagePoint[]>>
|
||||
@@ -17,6 +20,7 @@ export type StatsHomeData = {
|
||||
market: Record<UsageRange, MarketDay[]>
|
||||
tokenCost: Record<TokenProduct, TokenCostEntry[]>
|
||||
sessionCost: Record<TokenProduct, SessionCostEntry[]>
|
||||
country: Record<UsageRange, CountryEntry[]>
|
||||
}
|
||||
|
||||
const DAY_MS = 86_400_000
|
||||
@@ -28,6 +32,14 @@ type StatMetricRow = Omit<ModelStatMetric, "periodStart" | "periodEnd"> & {
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
}
|
||||
type ProviderMetricRow = Omit<ProviderStatMetric, "periodStart" | "periodEnd"> & {
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
}
|
||||
type GeoMetricRow = Omit<GeoStatMetric, "periodStart" | "periodEnd"> & {
|
||||
periodStart: number
|
||||
periodEnd: number
|
||||
}
|
||||
|
||||
type DateWindow = { start: number; end: number; previousStart: number; previousEnd: number }
|
||||
type Bucket = { start: number; end: number; label: string }
|
||||
@@ -45,20 +57,35 @@ type ModelAggregate = {
|
||||
totalCostMicrocents: number
|
||||
}
|
||||
|
||||
export const getStatsHomeData: () => Effect.Effect<StatsHomeData, DatabaseError, ModelStatRepo> = Effect.fn(
|
||||
"StatsHome.getData",
|
||||
)(function* () {
|
||||
export const getStatsHomeData: () => Effect.Effect<
|
||||
StatsHomeData,
|
||||
DatabaseError,
|
||||
ModelStatRepo | ProviderStatRepo | GeoStatRepo
|
||||
> = Effect.fn("StatsHome.getData")(function* () {
|
||||
const modelStats = yield* ModelStatRepo
|
||||
return buildStatsHomeData(yield* modelStats.listDaily())
|
||||
const providerStats = yield* ProviderStatRepo
|
||||
const geoStats = yield* GeoStatRepo
|
||||
const [modelRows, providerRows, geoRows] = yield* Effect.all(
|
||||
[modelStats.listDaily(), providerStats.listDaily(), geoStats.listDaily()],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return buildStatsHomeData(modelRows, providerRows, geoRows)
|
||||
})
|
||||
|
||||
function buildStatsHomeData(rows: ModelStatMetric[]): StatsHomeData {
|
||||
const normalized = rows.flatMap(normalizeStatRow)
|
||||
if (normalized.length === 0) return emptyStatsHomeData()
|
||||
function buildStatsHomeData(
|
||||
modelRows: ModelStatMetric[],
|
||||
providerRows: ProviderStatMetric[],
|
||||
geoRows: GeoStatMetric[],
|
||||
): StatsHomeData {
|
||||
const normalized = modelRows.flatMap(normalizeStatRow)
|
||||
const providers = providerRows.flatMap(normalizeProviderRow)
|
||||
const geo = geoRows.flatMap(normalizeGeoRow)
|
||||
const periods = [...normalized, ...providers, ...geo]
|
||||
if (periods.length === 0) return emptyStatsHomeData()
|
||||
|
||||
const earliest = Math.min(...normalized.map((row) => row.periodStart))
|
||||
const latest = Math.max(...normalized.map((row) => row.periodStart))
|
||||
const latestEnd = Math.max(...normalized.map((row) => row.periodEnd))
|
||||
const earliest = Math.min(...periods.map((row) => row.periodStart))
|
||||
const latest = Math.max(...periods.map((row) => row.periodStart))
|
||||
const latestEnd = Math.max(...periods.map((row) => row.periodEnd))
|
||||
|
||||
return {
|
||||
updatedAt: new Date(latestEnd).toISOString(),
|
||||
@@ -68,13 +95,14 @@ function buildStatsHomeData(rows: ModelStatMetric[]): StatsHomeData {
|
||||
leaderboard: createUsageProductRecord((product) =>
|
||||
createRangeRecord((range) => buildLeaderboard(normalized, product, getWindow(range, earliest, latest))),
|
||||
),
|
||||
market: createRangeRecord((range) => buildMarketShare(normalized, range, getWindow(range, earliest, latest))),
|
||||
market: createRangeRecord((range) => buildMarketShare(providers, range, getWindow(range, earliest, latest))),
|
||||
tokenCost: createTokenProductRecord((product) =>
|
||||
buildTokenCost(normalized, product, getWindow("1W", earliest, latest)),
|
||||
),
|
||||
sessionCost: createTokenProductRecord((product) =>
|
||||
buildSessionCost(normalized, product, getWindow("1W", earliest, latest)),
|
||||
),
|
||||
country: createRangeRecord((range) => buildCountryStats(geo, getWindow(range, earliest, latest))),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +114,7 @@ function emptyStatsHomeData(): StatsHomeData {
|
||||
market: createRangeRecord(() => []),
|
||||
tokenCost: createTokenProductRecord(() => []),
|
||||
sessionCost: createTokenProductRecord(() => []),
|
||||
country: createRangeRecord(() => []),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +161,7 @@ function buildLeaderboard(rows: StatMetricRow[], product: UsageProduct, window:
|
||||
}))
|
||||
}
|
||||
|
||||
function buildMarketShare(rows: StatMetricRow[], range: UsageRange, window: DateWindow) {
|
||||
function buildMarketShare(rows: ProviderMetricRow[], range: UsageRange, window: DateWindow) {
|
||||
return createBuckets(window, range).flatMap((bucket) => {
|
||||
const total = aggregateByProvider(rowsForProduct(rows, "All Users", bucket.start, bucket.end)).toSorted(
|
||||
(a, b) => b.tokens - a.tokens,
|
||||
@@ -160,6 +189,22 @@ function buildMarketShare(rows: StatMetricRow[], range: UsageRange, window: Date
|
||||
})
|
||||
}
|
||||
|
||||
function buildCountryStats(rows: GeoMetricRow[], window: DateWindow) {
|
||||
const countries = aggregateByCountry(rowsForProduct(rows, "All Users", window.start, window.end))
|
||||
.filter((item) => item.tokens > 0)
|
||||
.toSorted((a, b) => b.tokens - a.tokens)
|
||||
const totalTokens = countries.reduce((sum, item) => sum + item.tokens, 0)
|
||||
if (totalTokens === 0) return []
|
||||
|
||||
return countries.slice(0, 16).map((item, index) => ({
|
||||
country: item.country,
|
||||
continent: item.continent,
|
||||
tokens: round(item.tokens / 1_000_000_000_000, 4),
|
||||
share: round((item.tokens / totalTokens) * 100, 1),
|
||||
rank: index + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
function buildTokenCost(rows: StatMetricRow[], product: TokenProduct, window: DateWindow) {
|
||||
return aggregateByModel(rowsForProduct(rows, product, window.start, window.end))
|
||||
.flatMap((item) => {
|
||||
@@ -191,7 +236,12 @@ function buildSessionCost(rows: StatMetricRow[], product: TokenProduct, window:
|
||||
.slice(0, 17)
|
||||
}
|
||||
|
||||
function rowsForProduct(rows: StatMetricRow[], product: UsageProduct, start: number, end: number) {
|
||||
function rowsForProduct<T extends { periodStart: number; tier: string }>(
|
||||
rows: T[],
|
||||
product: UsageProduct,
|
||||
start: number,
|
||||
end: number,
|
||||
) {
|
||||
const windowRows = rows.filter((row) => row.periodStart >= start && row.periodStart < end)
|
||||
if (product !== "All Users") return windowRows.filter((row) => row.tier === product)
|
||||
|
||||
@@ -210,7 +260,7 @@ function aggregateByModel(rows: StatMetricRow[]) {
|
||||
)
|
||||
}
|
||||
|
||||
function aggregateByProvider(rows: StatMetricRow[]) {
|
||||
function aggregateByProvider(rows: ProviderMetricRow[]) {
|
||||
return Object.values(
|
||||
rows.reduce<Record<string, { provider: string; tokens: number }>>((result, row) => {
|
||||
result[row.provider] = {
|
||||
@@ -222,6 +272,19 @@ function aggregateByProvider(rows: StatMetricRow[]) {
|
||||
)
|
||||
}
|
||||
|
||||
function aggregateByCountry(rows: GeoMetricRow[]) {
|
||||
return Object.values(
|
||||
rows.reduce<Record<string, { country: string; continent: string; tokens: number }>>((result, row) => {
|
||||
result[row.country] = {
|
||||
country: row.country,
|
||||
continent: result[row.country]?.continent || row.continent,
|
||||
tokens: (result[row.country]?.tokens ?? 0) + row.totalTokens,
|
||||
}
|
||||
return result
|
||||
}, {}),
|
||||
)
|
||||
}
|
||||
|
||||
function combineModelAggregate(current: ModelAggregate | undefined, row: StatMetricRow): ModelAggregate {
|
||||
return {
|
||||
model: row.model,
|
||||
@@ -313,6 +376,37 @@ function normalizeStatRow(row: ModelStatMetric): StatMetricRow[] {
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeProviderRow(row: ProviderStatMetric): ProviderMetricRow[] {
|
||||
const periodStart = dateTime(row.periodStart)
|
||||
const periodEnd = dateTime(row.periodEnd)
|
||||
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return []
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
tier: normalizeTier(row.tier),
|
||||
provider: row.provider || "unknown",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeGeoRow(row: GeoStatMetric): GeoMetricRow[] {
|
||||
const periodStart = dateTime(row.periodStart)
|
||||
const periodEnd = dateTime(row.periodEnd)
|
||||
if (!Number.isFinite(periodStart) || !Number.isFinite(periodEnd)) return []
|
||||
return [
|
||||
{
|
||||
...row,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
tier: normalizeTier(row.tier),
|
||||
country: row.country || "ZZ",
|
||||
continent: row.continent || "",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function normalizeTier(value: string) {
|
||||
const normalized = value.toLowerCase()
|
||||
if (normalized === "paid" || normalized === "zen") return "Zen"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { and, asc, eq } from "drizzle-orm"
|
||||
import { Effect, Layer } from "effect"
|
||||
import * as Context from "effect/Context"
|
||||
import { DatabaseError, DrizzleClient } from "../database"
|
||||
@@ -16,9 +16,17 @@ import {
|
||||
|
||||
export type ProviderStatRow = typeof providerStat.$inferInsert
|
||||
export type ProviderStatAggregate = StatBaseAggregate & { provider: string }
|
||||
export type ProviderStatMetric = {
|
||||
periodStart: Date
|
||||
periodEnd: Date
|
||||
tier: string
|
||||
provider: string
|
||||
totalTokens: number
|
||||
}
|
||||
|
||||
export declare namespace ProviderStatRepo {
|
||||
export interface Service {
|
||||
readonly listDaily: () => Effect.Effect<ProviderStatMetric[], DatabaseError>
|
||||
readonly listByPeriod: (opts: {
|
||||
readonly grain: string
|
||||
readonly periodStart: Date
|
||||
@@ -39,6 +47,24 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
|
||||
Effect.gen(function* () {
|
||||
const db = yield* DrizzleClient
|
||||
|
||||
const listDaily = Effect.fn("ProviderStatRepo.listDaily")(function* () {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () =>
|
||||
db
|
||||
.select({
|
||||
periodStart: providerStat.period_start,
|
||||
periodEnd: providerStat.period_end,
|
||||
tier: providerStat.tier,
|
||||
provider: providerStat.provider,
|
||||
totalTokens: providerStat.total_tokens,
|
||||
})
|
||||
.from(providerStat)
|
||||
.where(and(eq(providerStat.grain, "day"), eq(providerStat.client, "all"), eq(providerStat.source, "all")))
|
||||
.orderBy(asc(providerStat.period_start)),
|
||||
catch: (cause) => DatabaseError.make({ cause }),
|
||||
})
|
||||
})
|
||||
|
||||
const listByPeriod = Effect.fn("ProviderStatRepo.listByPeriod")(function* (opts: {
|
||||
readonly grain: string
|
||||
readonly periodStart: Date
|
||||
@@ -113,7 +139,7 @@ export class ProviderStatRepo extends Context.Service<ProviderStatRepo, Provider
|
||||
)
|
||||
})
|
||||
|
||||
return ProviderStatRepo.of({ listByPeriod, upsert })
|
||||
return ProviderStatRepo.of({ listDaily, listByPeriod, upsert })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user