feat(usage): show 30-minute trend ticks and restore light theme
This commit is contained in:
1 parent
accb93c75c
commit
8f52a08177
12 files changed
+466
-403
No files matched your search
+1
-1
@@ -15,7 +15,7 @@
|
||||
: window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
document.documentElement.dataset.theme = theme;
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
document.documentElement.style.backgroundColor = theme === 'dark' ? '#111412' : '#f6f7f5';
|
||||
document.documentElement.style.backgroundColor = theme === 'dark' ? '#0b0d11' : '#f6f7f5';
|
||||
})();
|
||||
</script>
|
||||
<title>EasyCLIProxyAPI</title>
|
||||
|
||||
+32
-3
@@ -2649,7 +2649,16 @@ fn load_usage_overview(
|
||||
let timeline_sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
local_hour,
|
||||
CASE
|
||||
WHEN timestamp_ms > 0 THEN
|
||||
strftime('%Y-%m-%d-%H-', datetime(timestamp_ms / 1000, 'unixepoch', 'localtime'))
|
||||
|| CASE
|
||||
WHEN CAST(strftime('%M', datetime(timestamp_ms / 1000, 'unixepoch', 'localtime')) AS INTEGER) < 30
|
||||
THEN '00'
|
||||
ELSE '30'
|
||||
END
|
||||
ELSE local_hour || '-00'
|
||||
END,
|
||||
COALESCE(NULLIF(TRIM(model), ''), 'unknown'),
|
||||
COUNT(*),
|
||||
COALESCE(SUM(CASE WHEN failed = 0 THEN 1 ELSE 0 END), 0),
|
||||
@@ -2657,8 +2666,8 @@ fn load_usage_overview(
|
||||
COALESCE(SUM(CASE WHEN canceled != 0 THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(total_tokens), 0)
|
||||
FROM usage_events{}
|
||||
GROUP BY local_hour, 2
|
||||
ORDER BY local_hour ASC, 2 ASC
|
||||
GROUP BY 1, 2
|
||||
ORDER BY 1 ASC, 2 ASC
|
||||
"#,
|
||||
filter.clause
|
||||
);
|
||||
@@ -5541,6 +5550,26 @@ mod tests {
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeline_groups_requests_into_half_hour_buckets() {
|
||||
let root = test_root("sqlite-half-hour");
|
||||
let mut connection = open_test_database(&root);
|
||||
let first = sample_record("half-1", "2026-07-17T20:10:00+08:00", "gpt-a");
|
||||
let second = sample_record("half-2", "2026-07-17T20:40:00+08:00", "gpt-a");
|
||||
insert_usage_records(&mut connection, &[first, second]).unwrap();
|
||||
|
||||
let overview = load_usage_overview(&connection, &UsageQuery::default()).unwrap();
|
||||
assert_eq!(overview.timeline.len(), 2);
|
||||
assert_ne!(overview.timeline[0].hour, overview.timeline[1].hour);
|
||||
assert!(overview
|
||||
.timeline
|
||||
.iter()
|
||||
.all(|point| point.hour.ends_with("-00") || point.hour.ends_with("-30")));
|
||||
assert_eq!(overview.timeline.iter().map(|point| point.requests).sum::<u64>(), 2);
|
||||
drop(connection);
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overview_tps_uses_weighted_total_latency_without_requiring_ttft() {
|
||||
let root = test_root("tps-overview");
|
||||
|
||||
+24
-22
@@ -10,13 +10,13 @@ import {
|
||||
History,
|
||||
House,
|
||||
Languages,
|
||||
Lock,
|
||||
LogIn,
|
||||
MessageCircle,
|
||||
Network,
|
||||
PackageOpen,
|
||||
ServerCog,
|
||||
Settings,
|
||||
Sparkles,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import appLogo from './assets/logo.jpg';
|
||||
@@ -42,7 +42,7 @@ const pages = [
|
||||
{
|
||||
id: 'easy',
|
||||
labelKey: 'app.nav.easy',
|
||||
icon: Sparkles,
|
||||
icon: House,
|
||||
component: HomePage,
|
||||
},
|
||||
{
|
||||
@@ -52,16 +52,10 @@ const pages = [
|
||||
component: HomePage,
|
||||
},
|
||||
{
|
||||
id: 'versions',
|
||||
labelKey: 'app.nav.versions',
|
||||
icon: PackageOpen,
|
||||
component: VersionManagementPageWrapper,
|
||||
},
|
||||
{
|
||||
id: 'config',
|
||||
labelKey: 'app.nav.config',
|
||||
icon: Settings,
|
||||
component: ConfigPanelPage,
|
||||
id: 'api',
|
||||
labelKey: 'app.nav.api',
|
||||
icon: Network,
|
||||
component: ApiAccessPage,
|
||||
},
|
||||
{
|
||||
id: 'oauth',
|
||||
@@ -70,10 +64,10 @@ const pages = [
|
||||
component: OAuthManagementPage,
|
||||
},
|
||||
{
|
||||
id: 'api',
|
||||
labelKey: 'app.nav.api',
|
||||
icon: Network,
|
||||
component: ApiAccessPage,
|
||||
id: 'agents',
|
||||
labelKey: 'app.nav.agents',
|
||||
icon: Bot,
|
||||
component: AgentsPage,
|
||||
},
|
||||
{
|
||||
id: 'usage-records',
|
||||
@@ -82,10 +76,16 @@ const pages = [
|
||||
component: UsageRecordsPage,
|
||||
},
|
||||
{
|
||||
id: 'agents',
|
||||
labelKey: 'app.nav.agents',
|
||||
icon: Bot,
|
||||
component: AgentsPage,
|
||||
id: 'config',
|
||||
labelKey: 'app.nav.config',
|
||||
icon: Settings,
|
||||
component: ConfigPanelPage,
|
||||
},
|
||||
{
|
||||
id: 'versions',
|
||||
labelKey: 'app.nav.versions',
|
||||
icon: PackageOpen,
|
||||
component: VersionManagementPageWrapper,
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -309,12 +309,14 @@ function AppContent() {
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
disabled={locked}
|
||||
title={locked ? t('app.coreRequired.title') : undefined}
|
||||
title={locked ? t('app.nav.lockedHint') : undefined}
|
||||
onClick={() => select(page.id)}
|
||||
>
|
||||
<Icon size={17} aria-hidden="true" />
|
||||
<span>{t(page.labelKey)}</span>
|
||||
{updateIndicator ? (
|
||||
{locked ? (
|
||||
<Lock size={13} className="nav-lock-icon" aria-hidden="true" />
|
||||
) : updateIndicator ? (
|
||||
<i
|
||||
className={`nav-update-indicator ${updateIndicator}`}
|
||||
title={updateIndicator === 'processing'
|
||||
|
||||
+2
-1
@@ -242,6 +242,7 @@ export const jaOverrides = {
|
||||
'app.nav.quota': 'クォータ照会',
|
||||
'app.nav.usageRecords': '使用履歴',
|
||||
'app.nav.agents': 'エージェント設定',
|
||||
'app.nav.lockedHint': 'まずホームでコアサービスを起動してください',
|
||||
'app.coreRequired.title': '先にコアを起動してください',
|
||||
'app.coreRequired.description': '設定、OAuth、API 接続などを使用するには CPA コアを起動してください。',
|
||||
'app.contact.title': 'QQ でお問い合わせ',
|
||||
@@ -468,6 +469,7 @@ export const jaOverrides = {
|
||||
'usage.token.cacheRead': 'キャッシュ読み取り',
|
||||
'usage.token.cacheCreation': 'キャッシュ作成',
|
||||
'usage.trend.aria': 'Token の推移図。マウスまたは矢印キーで各時間帯とモデルの内訳を確認できます。',
|
||||
'usage.trend.bucket.30m': '30 分ごとに表示',
|
||||
'usage.trend.bucket.hour': '1 時間ごとに表示',
|
||||
'usage.trend.bucket.3h': '3 時間ごとに集計',
|
||||
'usage.trend.bucket.day': '日ごとに集計',
|
||||
@@ -475,7 +477,6 @@ export const jaOverrides = {
|
||||
'usage.trend.bucket.month': '月ごとに集計',
|
||||
'usage.trend.bucket.year': '年ごとに集計',
|
||||
'usage.trend.other': 'その他',
|
||||
'usage.trend.star': '主星',
|
||||
'usage.trend.tooltip.tokens': 'Token',
|
||||
'usage.analysis.models': 'モデル使用量',
|
||||
'usage.analysis.sources': 'リクエスト元',
|
||||
|
||||
@@ -191,6 +191,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'app.nav.quota': 'Quota Lookup',
|
||||
'app.nav.usageRecords': 'Usage',
|
||||
'app.nav.agents': 'Agent Configuration',
|
||||
'app.nav.lockedHint': 'Please start the core service on Home first',
|
||||
'app.coreRequired.title': 'Start the core first',
|
||||
'app.coreRequired.description': 'Start the CPA core to use settings, OAuth, API access, and other features.',
|
||||
'app.contact.title': 'Contact us on QQ',
|
||||
@@ -456,6 +457,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.token.cacheRead': 'Cache Read',
|
||||
'usage.token.cacheCreation': 'Cache Creation',
|
||||
'usage.trend.aria': 'Token trend chart. Hover or use arrow keys to inspect each bucket and model.',
|
||||
'usage.trend.bucket.30m': 'Shown every 30 minutes',
|
||||
'usage.trend.bucket.hour': 'Shown by hour',
|
||||
'usage.trend.bucket.3h': 'Grouped every 3 hours',
|
||||
'usage.trend.bucket.day': 'Grouped by day',
|
||||
@@ -463,7 +465,6 @@ export const en: Record<MessageKey, string> = {
|
||||
'usage.trend.bucket.month': 'Grouped by month',
|
||||
'usage.trend.bucket.year': 'Grouped by year',
|
||||
'usage.trend.other': 'Other',
|
||||
'usage.trend.star': 'Lead',
|
||||
'usage.trend.tooltip.tokens': 'Tokens',
|
||||
'usage.analysis.models': 'Model Usage',
|
||||
'usage.analysis.sources': 'Request Sources',
|
||||
|
||||
@@ -190,6 +190,7 @@ export const zhCN = {
|
||||
'app.nav.quota': '额度查询',
|
||||
'app.nav.usageRecords': '使用记录',
|
||||
'app.nav.agents': '智能体配置',
|
||||
'app.nav.lockedHint': '请先在首页启动内核服务',
|
||||
'app.coreRequired.title': '请先启动内核',
|
||||
'app.coreRequired.description': '启动 CPA 内核后,才能使用配置、OAuth、API 接入等功能。',
|
||||
'app.contact.title': '通过 QQ 联系我们',
|
||||
@@ -455,6 +456,7 @@ export const zhCN = {
|
||||
'usage.token.cacheRead': '缓存读取',
|
||||
'usage.token.cacheCreation': '缓存创建',
|
||||
'usage.trend.aria': 'Token 趋势图,可悬停或用方向键查看各时段和模型明细',
|
||||
'usage.trend.bucket.30m': '按 30 分钟展示',
|
||||
'usage.trend.bucket.hour': '按小时展示',
|
||||
'usage.trend.bucket.3h': '按 3 小时聚合',
|
||||
'usage.trend.bucket.day': '按天聚合',
|
||||
@@ -462,7 +464,6 @@ export const zhCN = {
|
||||
'usage.trend.bucket.month': '按月聚合',
|
||||
'usage.trend.bucket.year': '按年聚合',
|
||||
'usage.trend.other': '其他',
|
||||
'usage.trend.star': '主星',
|
||||
'usage.trend.tooltip.tokens': 'Token',
|
||||
'usage.analysis.models': '模型使用',
|
||||
'usage.analysis.sources': '请求来源',
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
Route,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Power,
|
||||
Terminal,
|
||||
Trash2,
|
||||
@@ -2180,7 +2179,6 @@ export function ConfigPanelPage() {
|
||||
onClick={generateApiKey}
|
||||
disabled={keyMutationBusy}
|
||||
>
|
||||
<Sparkles size={16} aria-hidden="true" />
|
||||
{t('config.keys.generate')}
|
||||
</button>
|
||||
<button type="submit" className="primary-button" disabled={keyMutationBusy}>
|
||||
|
||||
@@ -8,19 +8,13 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleDollarSign,
|
||||
Clock3,
|
||||
Columns3Cog,
|
||||
Database,
|
||||
FilterX,
|
||||
Key,
|
||||
Layers,
|
||||
List,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Wrench,
|
||||
@@ -38,6 +32,7 @@ import {
|
||||
formatTrendRangeLabel,
|
||||
niceCeiling,
|
||||
selectTrendAxisLabels,
|
||||
trendAxisTicks,
|
||||
smoothAreaPath,
|
||||
smoothLinePath,
|
||||
stackModelTokens,
|
||||
@@ -555,10 +550,7 @@ export function UsageRecordsPage() {
|
||||
<div className="usage-filter-row">
|
||||
<div className="usage-filter-group">
|
||||
<label className="usage-filter-item">
|
||||
<span className="usage-filter-label">
|
||||
<Clock3 size={13} />
|
||||
{t('usage.filter.timeRange')}
|
||||
</span>
|
||||
<span className="usage-filter-label">{t('usage.filter.timeRange')}</span>
|
||||
<select
|
||||
value={range}
|
||||
onChange={(event) => {
|
||||
@@ -578,10 +570,7 @@ export function UsageRecordsPage() {
|
||||
</label>
|
||||
|
||||
<label className="usage-filter-item">
|
||||
<span className="usage-filter-label">
|
||||
<Sparkles size={13} />
|
||||
{t('usage.filter.model')}
|
||||
</span>
|
||||
<span className="usage-filter-label">{t('usage.filter.model')}</span>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(event) => changeFilter(setModel, event.currentTarget.value)}
|
||||
@@ -597,10 +586,7 @@ export function UsageRecordsPage() {
|
||||
</label>
|
||||
|
||||
<label className="usage-filter-item">
|
||||
<span className="usage-filter-label">
|
||||
<Layers size={13} />
|
||||
{t('usage.column.provider')}
|
||||
</span>
|
||||
<span className="usage-filter-label">{t('usage.column.provider')}</span>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(event) => changeFilter(setProvider, event.currentTarget.value)}
|
||||
@@ -616,10 +602,7 @@ export function UsageRecordsPage() {
|
||||
</label>
|
||||
|
||||
<label className="usage-filter-item">
|
||||
<span className="usage-filter-label">
|
||||
<Terminal size={13} />
|
||||
{t('usage.filter.source')}
|
||||
</span>
|
||||
<span className="usage-filter-label">{t('usage.filter.source')}</span>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(event) => changeFilter(setSource, event.currentTarget.value)}
|
||||
@@ -635,10 +618,7 @@ export function UsageRecordsPage() {
|
||||
</label>
|
||||
|
||||
<label className="usage-filter-item">
|
||||
<span className="usage-filter-label">
|
||||
<Key size={13} />
|
||||
{t('apiAccess.field.key')}
|
||||
</span>
|
||||
<span className="usage-filter-label">{t('apiAccess.field.key')}</span>
|
||||
<select
|
||||
value={apiKeyHash}
|
||||
onChange={(event) => changeFilter(setApiKeyHash, event.currentTarget.value)}
|
||||
@@ -654,10 +634,7 @@ export function UsageRecordsPage() {
|
||||
</label>
|
||||
|
||||
<label className="usage-filter-item">
|
||||
<span className="usage-filter-label">
|
||||
<ShieldCheck size={13} />
|
||||
{t('usage.filter.result')}
|
||||
</span>
|
||||
<span className="usage-filter-label">{t('usage.filter.result')}</span>
|
||||
<select
|
||||
value={result}
|
||||
onChange={(event) => changeFilter(setResult, event.currentTarget.value)}
|
||||
@@ -987,6 +964,7 @@ function UsageTrend({
|
||||
stacked: [],
|
||||
areaLayers: [],
|
||||
totalLinePath: '',
|
||||
yTicks: [0, 0.25, 0.5, 0.75, 1],
|
||||
labelIndexes: [],
|
||||
compactSameDay: false,
|
||||
baseY: 146,
|
||||
@@ -1050,8 +1028,12 @@ function UsageTrend({
|
||||
}));
|
||||
}
|
||||
const totalLinePath = smoothLinePath(totalPoints);
|
||||
const yTicks = trendAxisTicks(maxTokens);
|
||||
|
||||
const labelIndexes = selectTrendAxisLabels(count, count > 36 ? 5 : 4);
|
||||
const labelIndexes = selectTrendAxisLabels(
|
||||
count,
|
||||
series.bucket === '30m' ? count : count > 48 ? 9 : count > 24 ? 8 : 7,
|
||||
);
|
||||
const compactSameDay =
|
||||
count > 1 &&
|
||||
series.points[0].start.toDateString() === series.points[count - 1].start.toDateString();
|
||||
@@ -1061,6 +1043,7 @@ function UsageTrend({
|
||||
stacked,
|
||||
areaLayers,
|
||||
totalLinePath,
|
||||
yTicks,
|
||||
labelIndexes,
|
||||
compactSameDay,
|
||||
baseY,
|
||||
@@ -1132,7 +1115,7 @@ function UsageTrend({
|
||||
<button
|
||||
type="button"
|
||||
key={model.key}
|
||||
className={`usage-trend-legend-item${isHidden ? ' is-hidden' : ''}${model.star ? ' is-star' : ''}`}
|
||||
className={`usage-trend-legend-item${isHidden ? ' is-hidden' : ''}`}
|
||||
aria-pressed={!isHidden}
|
||||
onClick={() =>
|
||||
setHiddenModels((current) =>
|
||||
@@ -1144,7 +1127,6 @@ function UsageTrend({
|
||||
>
|
||||
<span className="usage-trend-swatch" style={{ background: model.color }} />
|
||||
<span>{modelLabel(model.key, model.label)}</span>
|
||||
{model.star ? <em>{t('usage.trend.star')}</em> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -1159,9 +1141,9 @@ function UsageTrend({
|
||||
|
||||
<div className="usage-trend-chart">
|
||||
<div className="usage-trend-y-axis" aria-hidden="true">
|
||||
<span>{compactNumber(chart.maxTokens)}</span>
|
||||
<span>{compactNumber(Math.round(chart.maxTokens / 2))}</span>
|
||||
<span>0</span>
|
||||
{[...chart.yTicks].reverse().map((tick, index) => (
|
||||
<span key={`y-${tick}-${index}`}>{compactNumber(tick)}</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -1191,43 +1173,36 @@ function UsageTrend({
|
||||
<stop
|
||||
offset="0%"
|
||||
stopColor={model.color}
|
||||
stopOpacity={model.star ? 0.38 : 0.28}
|
||||
stopOpacity={0.28}
|
||||
/>
|
||||
<stop
|
||||
offset="100%"
|
||||
stopColor={model.color}
|
||||
stopOpacity={model.star ? 0.14 : 0.08}
|
||||
stopOpacity={0.08}
|
||||
/>
|
||||
</linearGradient>
|
||||
))}
|
||||
</defs>
|
||||
|
||||
<line
|
||||
x1="0"
|
||||
y1={chart.PT}
|
||||
x2="1000"
|
||||
y2={chart.PT}
|
||||
className="usage-trend-grid"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1={chart.PT + chart.UH / 2}
|
||||
x2="1000"
|
||||
y2={chart.PT + chart.UH / 2}
|
||||
className="usage-trend-grid"
|
||||
/>
|
||||
<line
|
||||
x1="0"
|
||||
y1={chart.baseY}
|
||||
x2="1000"
|
||||
y2={chart.baseY}
|
||||
className="usage-trend-baseline"
|
||||
/>
|
||||
{chart.yTicks.map((tick) => {
|
||||
const y = chart.baseY - (tick / chart.maxTokens) * chart.UH;
|
||||
const isBase = tick === 0;
|
||||
return (
|
||||
<line
|
||||
key={`grid-${tick}`}
|
||||
x1="0"
|
||||
y1={y}
|
||||
x2="1000"
|
||||
y2={y}
|
||||
className={isBase ? 'usage-trend-baseline' : 'usage-trend-grid'}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{chart.areaLayers.map((layer) => {
|
||||
if (!layer.hasTokens || !layer.areaPath) return null;
|
||||
return (
|
||||
<g key={layer.model.key} className={`usage-trend-layer${layer.model.star ? ' is-star' : ''}`}>
|
||||
<g key={layer.model.key} className="usage-trend-layer">
|
||||
<path
|
||||
d={layer.areaPath}
|
||||
fill={`url(#trend-grad-${layer.model.key.replace(/[^a-zA-Z0-9_-]+/g, '-')})`}
|
||||
@@ -1237,7 +1212,7 @@ function UsageTrend({
|
||||
d={layer.linePath}
|
||||
fill="none"
|
||||
stroke={layer.model.color}
|
||||
strokeWidth={layer.model.star ? 1.8 : 1.4}
|
||||
strokeWidth={1.4}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
@@ -1310,7 +1285,6 @@ function UsageTrend({
|
||||
<span className="usage-trend-swatch" style={{ background: model?.color }} />
|
||||
<span className="usage-trend-tooltip-label">
|
||||
{modelLabel(layer.key, model?.label ?? layer.key)}
|
||||
{model?.star ? <em>{t('usage.trend.star')}</em> : null}
|
||||
</span>
|
||||
<b>{compactNumber(layer.tokens)}</b>
|
||||
</div>
|
||||
|
||||
+45
-23
@@ -15,7 +15,7 @@ export type UsageTimelinePoint = {
|
||||
models?: UsageTimelineModel[];
|
||||
};
|
||||
|
||||
export type TrendBucket = 'hour' | '3h' | 'day' | 'week' | 'month' | 'year';
|
||||
export type TrendBucket = '30m' | 'hour' | '3h' | 'day' | 'week' | 'month' | 'year';
|
||||
|
||||
export type PreparedTrendPoint = {
|
||||
hour: string;
|
||||
@@ -34,7 +34,6 @@ export type PreparedTrendModel = {
|
||||
label: string;
|
||||
tokens: number;
|
||||
color: string;
|
||||
star: boolean;
|
||||
};
|
||||
|
||||
export type PreparedTrendSeries = {
|
||||
@@ -67,32 +66,37 @@ const MODEL_COLORS = [
|
||||
];
|
||||
|
||||
export function parseLocalHourKey(value: string): Date | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})-(\d{2})$/.exec(value.trim());
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})-(\d{2})(?:-(\d{2}))?$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const year = Number(match[1]);
|
||||
const month = Number(match[2]);
|
||||
const day = Number(match[3]);
|
||||
const hour = Number(match[4]);
|
||||
const minute = match[5] === undefined ? 0 : Number(match[5]);
|
||||
if (
|
||||
!Number.isInteger(year)
|
||||
|| !Number.isInteger(month)
|
||||
|| !Number.isInteger(day)
|
||||
|| !Number.isInteger(hour)
|
||||
|| !Number.isInteger(minute)
|
||||
|| month < 1
|
||||
|| month > 12
|
||||
|| day < 1
|
||||
|| day > 31
|
||||
|| hour < 0
|
||||
|| hour > 23
|
||||
|| minute < 0
|
||||
|| minute > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const date = new Date(year, month - 1, day, hour, 0, 0, 0);
|
||||
const date = new Date(year, month - 1, day, hour, minute, 0, 0);
|
||||
if (
|
||||
date.getFullYear() !== year
|
||||
|| date.getMonth() !== month - 1
|
||||
|| date.getDate() !== day
|
||||
|| date.getHours() !== hour
|
||||
|| date.getMinutes() !== minute
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -104,7 +108,8 @@ export function formatLocalHourKey(date: Date): string {
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hour = String(date.getHours()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}-${hour}`;
|
||||
const minute = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}-${hour}-${minute}`;
|
||||
}
|
||||
|
||||
function parseRangeDate(value: string | undefined): Date | null {
|
||||
@@ -119,6 +124,11 @@ function cloneDate(date: Date): Date {
|
||||
|
||||
export function startOfBucket(date: Date, bucket: TrendBucket): Date {
|
||||
const next = cloneDate(date);
|
||||
next.setSeconds(0, 0);
|
||||
if (bucket === '30m') {
|
||||
next.setMinutes(next.getMinutes() < 30 ? 0 : 30, 0, 0);
|
||||
return next;
|
||||
}
|
||||
next.setMinutes(0, 0, 0);
|
||||
if (bucket === 'hour') return next;
|
||||
if (bucket === '3h') {
|
||||
@@ -141,6 +151,10 @@ export function startOfBucket(date: Date, bucket: TrendBucket): Date {
|
||||
|
||||
export function addBucket(date: Date, bucket: TrendBucket): Date {
|
||||
const next = cloneDate(date);
|
||||
if (bucket === '30m') {
|
||||
next.setMinutes(next.getMinutes() + 30);
|
||||
return next;
|
||||
}
|
||||
if (bucket === 'hour') {
|
||||
next.setHours(next.getHours() + 1);
|
||||
return next;
|
||||
@@ -171,6 +185,9 @@ export function endOfBucket(start: Date, bucket: TrendBucket): Date {
|
||||
|
||||
function estimateBucketCount(start: Date, end: Date, bucket: TrendBucket): number {
|
||||
if (end.getTime() <= start.getTime()) return 1;
|
||||
if (bucket === '30m') {
|
||||
return Math.max(1, Math.ceil((end.getTime() - start.getTime()) / (HOUR_MS / 2)));
|
||||
}
|
||||
if (bucket === 'hour') {
|
||||
return Math.max(1, Math.ceil((end.getTime() - start.getTime()) / HOUR_MS));
|
||||
}
|
||||
@@ -187,7 +204,7 @@ function estimateBucketCount(start: Date, end: Date, bucket: TrendBucket): numbe
|
||||
return Math.max(1, count);
|
||||
}
|
||||
|
||||
const BUCKET_ORDER: TrendBucket[] = ['hour', '3h', 'day', 'week', 'month', 'year'];
|
||||
const BUCKET_ORDER: TrendBucket[] = ['30m', 'hour', '3h', 'day', 'week', 'month', 'year'];
|
||||
|
||||
export function chooseTrendBucket(start: Date, end: Date): TrendBucket {
|
||||
for (const bucket of BUCKET_ORDER) {
|
||||
@@ -239,20 +256,27 @@ export function niceCeiling(value: number): number {
|
||||
return nice * magnitude;
|
||||
}
|
||||
|
||||
export function trendAxisTicks(max: number): number[] {
|
||||
if (max <= 1) return [0, 1];
|
||||
if (Number.isInteger(max) && max <= 8) {
|
||||
const step = max <= 4 ? 1 : 2;
|
||||
const ticks: number[] = [];
|
||||
for (let value = 0; value <= max; value += step) ticks.push(value);
|
||||
if (ticks[ticks.length - 1] !== max) ticks.push(max);
|
||||
return ticks;
|
||||
export function trendAxisTicks(max: number, targetCount = 5): number[] {
|
||||
if (!Number.isFinite(max) || max <= 0) return [0, 1];
|
||||
const parts = Math.max(2, Math.round(targetCount));
|
||||
if (Number.isInteger(max) && max <= parts) {
|
||||
return Array.from({ length: max + 1 }, (_, index) => index);
|
||||
}
|
||||
const mid = max / 2;
|
||||
return Number.isInteger(mid) ? [0, mid, max] : [0, max];
|
||||
const rawStep = max / parts;
|
||||
const exponent = Math.floor(Math.log10(rawStep));
|
||||
const magnitude = 10 ** exponent;
|
||||
const normalized = rawStep / magnitude;
|
||||
const nice = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
const step = nice * magnitude;
|
||||
const ticks: number[] = [];
|
||||
for (let value = 0; value < max && ticks.length < 12; value += step) {
|
||||
ticks.push(Number.isInteger(value) ? value : Number(value.toFixed(6)));
|
||||
}
|
||||
if (ticks[ticks.length - 1] !== max) ticks.push(max);
|
||||
return ticks;
|
||||
}
|
||||
|
||||
export function selectTrendAxisLabels(count: number, maxLabels = 6): number[] {
|
||||
export function selectTrendAxisLabels(count: number, maxLabels = 8): number[] {
|
||||
if (count <= 0) return [];
|
||||
if (count <= maxLabels) return Array.from({ length: count }, (_, index) => index);
|
||||
const last = count - 1;
|
||||
@@ -294,7 +318,7 @@ export function formatTrendAxisLabel(
|
||||
locale: string,
|
||||
options?: { compactSameDay?: boolean },
|
||||
): string {
|
||||
if (bucket === 'hour' || bucket === '3h') {
|
||||
if (bucket === '30m' || bucket === 'hour' || bucket === '3h') {
|
||||
if (options?.compactSameDay) return formatTime(point.start, locale);
|
||||
return `${formatMonthDay(point.start, locale)} ${formatTime(point.start, locale)}`;
|
||||
}
|
||||
@@ -386,7 +410,7 @@ export function buildUsageTrendSeries(
|
||||
}
|
||||
|
||||
if (parsed.length === 0) {
|
||||
return { bucket: 'hour', points: [], totals, peak: null, models: [] };
|
||||
return { bucket: '30m', points: [], totals, peak: null, models: [] };
|
||||
}
|
||||
|
||||
const rangeStart = parseRangeDate(range?.start);
|
||||
@@ -396,8 +420,8 @@ export function buildUsageTrendSeries(
|
||||
const spanStart = rangeStart
|
||||
? new Date(Math.min(rangeStart.getTime(), first.getTime()))
|
||||
: first;
|
||||
const spanEndSource = rangeEnd ?? (rangeStart ? now : new Date(last.getTime() + HOUR_MS));
|
||||
const spanEnd = new Date(Math.max(spanEndSource.getTime(), last.getTime() + HOUR_MS));
|
||||
const spanEndSource = rangeEnd ?? (rangeStart ? now : new Date(last.getTime() + HOUR_MS / 2));
|
||||
const spanEnd = new Date(Math.max(spanEndSource.getTime(), last.getTime() + HOUR_MS / 2));
|
||||
|
||||
const bucket = chooseTrendBucket(spanStart, spanEnd);
|
||||
const seriesStart = startOfBucket(spanStart, bucket);
|
||||
@@ -436,7 +460,6 @@ export function buildUsageTrendSeries(
|
||||
label: labels.get(key) ?? key,
|
||||
tokens,
|
||||
color: modelColor(index, key),
|
||||
star: index === 0 && key !== OTHER_MODEL_KEY,
|
||||
}));
|
||||
if (rest.length) {
|
||||
models.push({
|
||||
@@ -444,7 +467,6 @@ export function buildUsageTrendSeries(
|
||||
label: OTHER_MODEL_KEY,
|
||||
tokens: rest.reduce((sum, [, tokens]) => sum + tokens, 0),
|
||||
color: modelColor(models.length, OTHER_MODEL_KEY),
|
||||
star: models.length === 0,
|
||||
});
|
||||
}
|
||||
const peak = series.reduce<PreparedTrendPoint | null>((best, point) => {
|
||||
|
||||
+290
-265
@@ -110,120 +110,226 @@
|
||||
--theme-fff8eb: #fff8eb;
|
||||
--theme-fffdf8: #fffdf8;
|
||||
--theme-ffffff: #ffffff;
|
||||
|
||||
--bg-secondary: #f6f7f5;
|
||||
--bg-primary: #ffffff;
|
||||
--bg-tertiary: #eff3f0;
|
||||
--bg-hover: #f6f8f6;
|
||||
--bg-quinary: #f9faf9;
|
||||
--bg-card: #ffffff;
|
||||
--bg-error-light: #fff1ee;
|
||||
--text-primary: #202521;
|
||||
--text-secondary: #556159;
|
||||
--text-tertiary: #657168;
|
||||
--text-quaternary: #7d877f;
|
||||
--text-muted: #657168;
|
||||
--border-color: #e2e8e3;
|
||||
--border-secondary: #edf1ee;
|
||||
--border-primary: #cfd7d0;
|
||||
--border-hover: #c0c8c1;
|
||||
--primary-color: #0f766e;
|
||||
--primary-hover: #0b5d57;
|
||||
--primary-active: #094c47;
|
||||
--primary-contrast: #ffffff;
|
||||
--success-color: #2f6b3f;
|
||||
--warning-color: #c77b30;
|
||||
--error-color: #9f2d20;
|
||||
--danger-color: #9f2d20;
|
||||
--info-color: #0f766e;
|
||||
--amber-color: #c77b30;
|
||||
--amber-text: #8b5b21;
|
||||
--success-badge-bg: #eef7f0;
|
||||
--success-badge-text: #2f6b3f;
|
||||
--success-badge-border: #b6d3bd;
|
||||
--failure-badge-bg: #fff1ee;
|
||||
--failure-badge-text: #9f2d20;
|
||||
--failure-badge-border: #dfb7b0;
|
||||
--count-badge-bg: #eff3f0;
|
||||
--count-badge-text: var(--text-primary);
|
||||
--ui-accent: #0f766e;
|
||||
--ui-accent-strong: #0b5d57;
|
||||
--ui-accent-soft: #d9f0ec;
|
||||
--ui-focus-ring: rgba(15, 118, 110, 0.28);
|
||||
--ui-accent-shadow: rgba(15, 118, 110, 0.18);
|
||||
--ui-panel-shadow: 0 8px 24px rgba(32, 42, 36, 0.07);
|
||||
--shadow-sm: 0 1px 2px rgba(32, 42, 36, 0.06);
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
--theme-2d2a26: #ece9e2;
|
||||
--theme-2f6b3a: #83d295;
|
||||
--theme-2f6b3f: #83d29a;
|
||||
--theme-342f2a: #d3cec6;
|
||||
--theme-344ca1: #9fb4ff;
|
||||
--theme-39352f: #d3cfc7;
|
||||
--theme-3f3550: #cdc8d6;
|
||||
--theme-3f6f98: #c6d5e1;
|
||||
--theme-42637f: #c8d3dd;
|
||||
--theme-433f39: #d4d0c9;
|
||||
--theme-4d78a8: #a5b7ca;
|
||||
--theme-4f4a43: #d5d1ca;
|
||||
--theme-4f825c: #cbdbd0;
|
||||
--theme-554075: #d0c8dc;
|
||||
--theme-5d9a6c: #abc4b1;
|
||||
--theme-5f5a53: #c8cdd5;
|
||||
--theme-654f91: #b8a4e2;
|
||||
--theme-655f57: #d8d4cd;
|
||||
--theme-66709a: #adb1c2;
|
||||
--theme-6684a1: #acb8c4;
|
||||
--theme-6d6760: #d8d5ce;
|
||||
--theme-6f6961: #d9d5ce;
|
||||
--theme-716b61: #d9d5ce;
|
||||
--theme-777067: #d9d6cf;
|
||||
--theme-7a746c: #aab2bd;
|
||||
--theme-805a24: #e3b778;
|
||||
--theme-8069ad: #b8a4e2;
|
||||
--theme-817b73: #dbd7d1;
|
||||
--theme-866b4d: #dcd4cb;
|
||||
--theme-8a625d: #dad6d0;
|
||||
--theme-8a847c: #929ba7;
|
||||
--theme-8b5b21: #e3b778;
|
||||
--theme-8d8377: #bcb8b3;
|
||||
--theme-8f3025: #e6c2be;
|
||||
--theme-918a81: #bbb8b4;
|
||||
--theme-9a938a: #bbb8b4;
|
||||
--theme-9a958d: #747e8a;
|
||||
--theme-9b958c: #747e8a;
|
||||
--theme-9ca9df: #9ea8d1;
|
||||
--theme-9d8abd: #b4aac6;
|
||||
--theme-9db8a5: #b0c0b4;
|
||||
--theme-9f2d20: #ff9385;
|
||||
--theme-a33b2b: #f58d7f;
|
||||
--theme-a83f32: #f19387;
|
||||
--theme-aaa49b: #69737f;
|
||||
--theme-b64132: #f39a8e;
|
||||
--theme-b6d3bd: #315b3d;
|
||||
--theme-b8d1bb: #315b3d;
|
||||
--theme-b9d5bf: #315b3d;
|
||||
--theme-bdb5a9: #beb9b1;
|
||||
--theme-bf8a43: #d0bb9f;
|
||||
--theme-c77b30: #d6b799;
|
||||
--theme-c7c2b8: #4a5564;
|
||||
--theme-c9c4ba: #bebab2;
|
||||
--theme-c9d6e2: #303c47;
|
||||
--theme-c9d8ca: #315b3d;
|
||||
--theme-c9dfcf: #315b3d;
|
||||
--theme-d5d2cb: #3b4451;
|
||||
--theme-d8d1eb: #493d61;
|
||||
--theme-d8d2c8: #363a46;
|
||||
--theme-d8d4cc: #3b4451;
|
||||
--theme-d9d4c9: #363a45;
|
||||
--theme-dce2fb: #293a61;
|
||||
--theme-dce8df: #203c29;
|
||||
--theme-ddd8ce: #343843;
|
||||
--theme-dfb7b0: #633832;
|
||||
--theme-dfbf91: #654a26;
|
||||
--theme-dfdcd4: #2a313b;
|
||||
--theme-e2b6ae: #633832;
|
||||
--theme-e2ddd3: #443d2f;
|
||||
--theme-e3e1db: #303743;
|
||||
--theme-e4d0af: #594328;
|
||||
--theme-e5e1d9: #30343e;
|
||||
--theme-e8e4dc: #40392c;
|
||||
--theme-e8e5dc: #2a313b;
|
||||
--theme-ece8df: #3e382b;
|
||||
--theme-eeeae2: #2a3039;
|
||||
--theme-eeece6: #252b34;
|
||||
--theme-eef1ff: #1b2436;
|
||||
--theme-eef7f0: #172a1e;
|
||||
--theme-efc9c2: #633832;
|
||||
--theme-f0ede6: #3b3529;
|
||||
--theme-f0eee8: #151a21;
|
||||
--theme-f1ecfb: #231e31;
|
||||
--theme-f1f6fa: #192630;
|
||||
--theme-f1f8f1: #172a1e;
|
||||
--theme-f2efe8: #3a3428;
|
||||
--theme-f2f0ea: #393528;
|
||||
--theme-f2f7f2: #172a1e;
|
||||
--theme-f3f8f4: #172a1e;
|
||||
--theme-f4f1ea: #20262f;
|
||||
--theme-f4f2ec: #1f2229;
|
||||
--theme-f5f1ff: #231e31;
|
||||
--theme-f5f7ff: #1b2436;
|
||||
--theme-f6f2ec: #1f2229;
|
||||
--theme-f7f3ff: #231e31;
|
||||
--theme-f8f5ee: #1f2229;
|
||||
--theme-f8f5ef: #1f2229;
|
||||
--theme-f8f5ff: #231e31;
|
||||
--theme-f8f6f1: #1f2229;
|
||||
--theme-faf8f2: #1f2229;
|
||||
--theme-faf8f4: #1f2229;
|
||||
--theme-faf9f5: #101419;
|
||||
--theme-fdf1ee: #321c1a;
|
||||
--theme-fff: #1c2129;
|
||||
--theme-fff1ee: #321c1a;
|
||||
--theme-fff7e9: #302417;
|
||||
--theme-fff8eb: #302417;
|
||||
--theme-fffdf8: #191e25;
|
||||
--theme-ffffff: #1c2129;
|
||||
|
||||
--bg-secondary: #0b0d11;
|
||||
--bg-primary: #141720;
|
||||
--bg-tertiary: #1c212c;
|
||||
--bg-hover: #252b38;
|
||||
--bg-quinary: #10131a;
|
||||
--bg-card: #141720;
|
||||
--bg-error-light: rgba(198, 87, 70, 0.18);
|
||||
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #cbd5e1;
|
||||
--text-tertiary: #94a3b8;
|
||||
--text-quaternary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
|
||||
--border-color: #222836;
|
||||
--border-secondary: #222836;
|
||||
--border-primary: #2e3646;
|
||||
--border-hover: #3a4356;
|
||||
|
||||
--primary-color: #5dd3c7;
|
||||
--primary-hover: #83e0d6;
|
||||
--primary-active: #4ec4b8;
|
||||
--primary-contrast: #0b0d11;
|
||||
|
||||
--success-color: #34d399;
|
||||
--viz-success: #10b981;
|
||||
--warning-color: #c65746;
|
||||
--error-color: #c65746;
|
||||
--danger-color: #c65746;
|
||||
--info-color: #f8fafc;
|
||||
--amber-color: #d97706;
|
||||
--amber-text: #fcd34d;
|
||||
|
||||
--success-badge-bg: rgba(16, 185, 129, 0.16);
|
||||
--success-badge-text: #6ee7b7;
|
||||
--success-badge-border: rgba(52, 211, 153, 0.38);
|
||||
|
||||
--failure-badge-bg: rgba(198, 87, 70, 0.18);
|
||||
--failure-badge-text: #fca5a5;
|
||||
--failure-badge-border: rgba(198, 87, 70, 0.35);
|
||||
|
||||
--count-badge-bg: rgba(248, 250, 252, 0.12);
|
||||
--count-badge-text: var(--text-primary);
|
||||
|
||||
--ui-accent: #5dd3c7;
|
||||
--ui-accent-strong: #83e0d6;
|
||||
--ui-accent-contrast: #0b0d11;
|
||||
--ui-accent-soft: rgba(45, 169, 158, 0.2);
|
||||
--ui-focus-ring: rgba(93, 211, 199, 0.28);
|
||||
--ui-accent-shadow: rgba(0, 0, 0, 0.22);
|
||||
--ui-panel-shadow: 0 1px 2px rgba(0, 0, 0, 0.28), 0 12px 32px -10px rgba(0, 0, 0, 0.45);
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.28);
|
||||
|
||||
--theme-2d2a26: #f8fafc;
|
||||
--theme-2f2c28: #ece9e2;
|
||||
--theme-2f6b3a: #34d399;
|
||||
--theme-2f6b3f: #34d399;
|
||||
--theme-342f2a: #ece9e2;
|
||||
--theme-344ca1: #9c958d;
|
||||
--theme-39352f: #dfdcd4;
|
||||
--theme-3f3550: #c9c3bb;
|
||||
--theme-3f6f98: #9c958d;
|
||||
--theme-42637f: #9c958d;
|
||||
--theme-433f39: #d3cfc7;
|
||||
--theme-4b4742: #c9c3bb;
|
||||
--theme-4d78a8: #9c958d;
|
||||
--theme-4f4a43: #cbd5e1;
|
||||
--theme-4f825c: #34d399;
|
||||
--theme-554075: #b0aaa2;
|
||||
--theme-59544d: #b0aaa2;
|
||||
--theme-5d9a6c: #34d399;
|
||||
--theme-5f5a53: #94a3b8;
|
||||
--theme-654f91: #b0aaa2;
|
||||
--theme-655f57: #a29c95;
|
||||
--theme-66709a: #c9c3bb;
|
||||
--theme-6684a1: #c9c3bb;
|
||||
--theme-6b655d: #c9c3bb;
|
||||
--theme-6d6760: #cbd5e1;
|
||||
--theme-6f6961: #9c958d;
|
||||
--theme-716b61: #94a3b8;
|
||||
--theme-777067: #9c958d;
|
||||
--theme-7a746c: #94a3b8;
|
||||
--theme-805a24: #fcd34d;
|
||||
--theme-8069ad: #8a847c;
|
||||
--theme-817b72: #8a847c;
|
||||
--theme-817b73: #8a847c;
|
||||
--theme-866b4d: #8a847c;
|
||||
--theme-8a625d: #8a847c;
|
||||
--theme-8a847c: #64748b;
|
||||
--theme-8b5b21: #fcd34d;
|
||||
--theme-8d8377: #6d6760;
|
||||
--theme-8f3025: #c65746;
|
||||
--theme-918a81: #6d6760;
|
||||
--theme-9a938a: #5f5a53;
|
||||
--theme-9a958d: #334155;
|
||||
--theme-9b958c: #334155;
|
||||
--theme-9ca9df: #8b8680;
|
||||
--theme-9d8abd: #8b8680;
|
||||
--theme-9db8a5: #34d399;
|
||||
--theme-9f2d20: #c65746;
|
||||
--theme-a33b2b: #c65746;
|
||||
--theme-a83f32: #c65746;
|
||||
--theme-aaa49b: #334155;
|
||||
--theme-b64132: #c65746;
|
||||
--theme-b6d3bd: rgba(16, 185, 129, 0.3);
|
||||
--theme-b8d1bb: rgba(16, 185, 129, 0.3);
|
||||
--theme-b9d5bf: rgba(16, 185, 129, 0.3);
|
||||
--theme-bd5e31: #c65746;
|
||||
--theme-bdb5a9: #3a3530;
|
||||
--theme-bf8a43: #d97706;
|
||||
--theme-c77b30: #d97706;
|
||||
--theme-c7c2b8: #222836;
|
||||
--theme-c9c4ba: #222836;
|
||||
--theme-c9d6e2: #222836;
|
||||
--theme-c9d8ca: rgba(16, 185, 129, 0.3);
|
||||
--theme-c9dfcf: rgba(16, 185, 129, 0.3);
|
||||
--theme-d3d0c9: #2e3646;
|
||||
--theme-d5d2cb: #2e3646;
|
||||
--theme-d7a447: #d97706;
|
||||
--theme-d8d1eb: #3a3530;
|
||||
--theme-d8d2c8: #3a3530;
|
||||
--theme-d8d4cc: #222836;
|
||||
--theme-d9d4c9: #1c212c;
|
||||
--theme-dce2fb: rgba(139, 134, 128, 0.35);
|
||||
--theme-dce8df: rgba(16, 185, 129, 0.25);
|
||||
--theme-ddd8ce: #2e2a26;
|
||||
--theme-dfb7b0: rgba(198, 87, 70, 0.35);
|
||||
--theme-dfbf91: rgba(217, 119, 6, 0.35);
|
||||
--theme-dfdcd4: #1c212c;
|
||||
--theme-e2b6ae: rgba(198, 87, 70, 0.35);
|
||||
--theme-e2ddd3: #2e2a26;
|
||||
--theme-e3ded5: #3a3530;
|
||||
--theme-e3e1db: #222836;
|
||||
--theme-e4d0af: #3a3530;
|
||||
--theme-e5e1d9: #2e2a26;
|
||||
--theme-e8e4dc: #1c212c;
|
||||
--theme-e8e5dc: #1c212c;
|
||||
--theme-ece8df: #1c212c;
|
||||
--theme-eeeae2: #1c212c;
|
||||
--theme-eeece6: #1c212c;
|
||||
--theme-eef1ff: rgba(139, 134, 128, 0.15);
|
||||
--theme-eef7f0: rgba(16, 185, 129, 0.12);
|
||||
--theme-efc9c2: rgba(198, 87, 70, 0.3);
|
||||
--theme-f0ede6: #12151c;
|
||||
--theme-f0eee8: #141720;
|
||||
--theme-f1ecfb: #1c212c;
|
||||
--theme-f1f6fa: #1c212c;
|
||||
--theme-f1f8f1: rgba(16, 185, 129, 0.1);
|
||||
--theme-f2efe8: #141720;
|
||||
--theme-f2f0ea: #141720;
|
||||
--theme-f2f7f2: rgba(16, 185, 129, 0.1);
|
||||
--theme-f3f8f4: rgba(16, 185, 129, 0.1);
|
||||
--theme-f4f1ea: #10131a;
|
||||
--theme-f4f2ec: #10131a;
|
||||
--theme-f5f1ff: #10131a;
|
||||
--theme-f5f7ff: #10131a;
|
||||
--theme-f6f2ec: #10131a;
|
||||
--theme-f7f3ff: #10131a;
|
||||
--theme-f8f5ee: #0f1218;
|
||||
--theme-f8f5ef: #0f1218;
|
||||
--theme-f8f5ff: #0f1218;
|
||||
--theme-f8f6f1: #0f1218;
|
||||
--theme-faf8f2: #0f1218;
|
||||
--theme-faf8f4: #0f1218;
|
||||
--theme-faf9f5: #0b0d11;
|
||||
--theme-fdf1ee: rgba(198, 87, 70, 0.1);
|
||||
--theme-fff: #141720;
|
||||
--theme-fff1ee: rgba(198, 87, 70, 0.16);
|
||||
--theme-fff7e9: rgba(217, 119, 6, 0.14);
|
||||
--theme-fff8eb: rgba(217, 119, 6, 0.14);
|
||||
--theme-fffdf8: #141720;
|
||||
--theme-ffffff: #141720;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -296,68 +402,6 @@
|
||||
--theme-ffffff: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] {
|
||||
--ui-accent: #5dd3c7;
|
||||
--ui-accent-strong: #83e0d6;
|
||||
--ui-accent-soft: rgba(45, 169, 158, 0.2);
|
||||
--ui-focus-ring: rgba(93, 211, 199, 0.36);
|
||||
--ui-accent-shadow: rgba(0, 0, 0, 0.22);
|
||||
--ui-panel-shadow: 0 10px 28px rgba(0, 0, 0, 0.18);
|
||||
--theme-2d2a26: #edf2ee;
|
||||
--theme-342f2a: #e4eae5;
|
||||
--theme-39352f: #dce3dd;
|
||||
--theme-433f39: #d5ddd6;
|
||||
--theme-4f4a43: #ccd5ce;
|
||||
--theme-5f5a53: #c0cac2;
|
||||
--theme-655f57: #b8c2ba;
|
||||
--theme-6d6760: #b0bbb2;
|
||||
--theme-6f6961: #adb8af;
|
||||
--theme-716b61: #aab5ac;
|
||||
--theme-777067: #a7b2a9;
|
||||
--theme-7a746c: #9eaaa1;
|
||||
--theme-817b73: #96a299;
|
||||
--theme-8a847c: #8d998f;
|
||||
--theme-8d8377: #8a968d;
|
||||
--theme-918a81: #849087;
|
||||
--theme-9a938a: #7e8a81;
|
||||
--theme-9a958d: #768279;
|
||||
--theme-9b958c: #78847b;
|
||||
--theme-aaa49b: #6f7b72;
|
||||
--theme-bdb5a9: #59655c;
|
||||
--theme-c7c2b8: #4b584e;
|
||||
--theme-c9c4ba: #465349;
|
||||
--theme-d5d2cb: #3a473d;
|
||||
--theme-d8d2c8: #354137;
|
||||
--theme-d8d4cc: #3b483e;
|
||||
--theme-d9d4c9: #344036;
|
||||
--theme-ddd8ce: #303b32;
|
||||
--theme-dfdcd4: #29352b;
|
||||
--theme-e2ddd3: #354238;
|
||||
--theme-e3e1db: #2f3b32;
|
||||
--theme-e5e1d9: #2d3830;
|
||||
--theme-e8e4dc: #28332b;
|
||||
--theme-e8e5dc: #263129;
|
||||
--theme-ece8df: #232d26;
|
||||
--theme-eeeae2: #29342c;
|
||||
--theme-eeece6: #242e27;
|
||||
--theme-f0ede6: #222c25;
|
||||
--theme-f0eee8: #171c18;
|
||||
--theme-f2efe8: #202922;
|
||||
--theme-f2f0ea: #212a23;
|
||||
--theme-f4f1ea: #1e2721;
|
||||
--theme-f4f2ec: #1d251f;
|
||||
--theme-f6f2ec: #19211b;
|
||||
--theme-f8f5ee: #1c231e;
|
||||
--theme-f8f5ef: #1c231e;
|
||||
--theme-f8f6f1: #1c231e;
|
||||
--theme-faf8f2: #1a211c;
|
||||
--theme-faf8f4: #1a211c;
|
||||
--theme-faf9f5: #111412;
|
||||
--theme-fff: #1b211c;
|
||||
--theme-fffdf8: #1a201b;
|
||||
--theme-ffffff: #1b211c;
|
||||
}
|
||||
|
||||
:root {
|
||||
/* Shared typography scale for captions, controls, headings, and metrics. */
|
||||
--font-size-caption: 12px;
|
||||
@@ -446,10 +490,10 @@ textarea:focus-visible {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
padding: 20px 16px;
|
||||
border-right: 1px solid var(--theme-e3e1db);
|
||||
background: var(--theme-f0eee8);
|
||||
color: var(--theme-2d2a26);
|
||||
padding: 20px 14px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
@@ -507,7 +551,7 @@ textarea:focus-visible {
|
||||
|
||||
.nav-section button {
|
||||
display: grid;
|
||||
grid-template-columns: 19px minmax(0, 1fr) 10px;
|
||||
grid-template-columns: 19px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
@@ -527,6 +571,11 @@ textarea:focus-visible {
|
||||
color: var(--theme-7a746c);
|
||||
}
|
||||
|
||||
.nav-lock-icon {
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.nav-section button span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -604,26 +653,28 @@ textarea:focus-visible {
|
||||
.sidebar-easy-entry {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
min-height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 8px;
|
||||
background: var(--theme-fffdf8);
|
||||
color: var(--theme-4f4a43);
|
||||
border: 1px dashed var(--border-primary);
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
transition: border-color 140ms ease, background 140ms ease, color 140ms ease;
|
||||
}
|
||||
|
||||
.sidebar-easy-entry:hover {
|
||||
border-color: color-mix(in srgb, var(--ui-accent) 36%, var(--theme-d8d4cc));
|
||||
background: var(--theme-e8e5dc);
|
||||
color: var(--theme-2d2a26);
|
||||
border-style: solid;
|
||||
border-color: var(--border-hover);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-easy-entry.active {
|
||||
border-color: color-mix(in srgb, var(--ui-accent) 42%, var(--theme-d8d4cc));
|
||||
background: var(--ui-accent-soft);
|
||||
color: var(--ui-accent-strong);
|
||||
border-style: solid;
|
||||
border-color: var(--border-primary);
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-easy-entry span {
|
||||
@@ -641,9 +692,9 @@ textarea:focus-visible {
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 8px;
|
||||
background: var(--theme-fffdf8);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9px;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.sidebar-theme-selector button {
|
||||
@@ -666,9 +717,9 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.sidebar-theme-selector button.active {
|
||||
background: var(--theme-dfdcd4);
|
||||
box-shadow: inset 0 0 0 1px var(--theme-d5d2cb);
|
||||
color: var(--theme-2d2a26);
|
||||
background: var(--bg-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-language {
|
||||
@@ -682,12 +733,12 @@ textarea:focus-visible {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
min-height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 8px;
|
||||
background: var(--theme-fffdf8);
|
||||
color: var(--theme-716b61);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -781,10 +832,10 @@ textarea:focus-visible {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--theme-d8d4cc);
|
||||
border-radius: 7px;
|
||||
background: var(--theme-fffdf8);
|
||||
color: var(--theme-4f4a43);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 9px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -2455,6 +2506,13 @@ textarea:focus-visible {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.icon-button.copied,
|
||||
.icon-button.quiet.copied {
|
||||
border-color: var(--success-badge-border) !important;
|
||||
background: var(--success-badge-bg) !important;
|
||||
color: var(--success-color) !important;
|
||||
}
|
||||
|
||||
.config-key-list {
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
@@ -3197,7 +3255,7 @@ textarea:focus-visible {
|
||||
|
||||
.management-header {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
@@ -4206,22 +4264,22 @@ textarea:focus-visible {
|
||||
grid-template-columns: 30px minmax(0, 1fr) minmax(24px, auto);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 54px;
|
||||
height: 48px;
|
||||
min-width: 0;
|
||||
padding: 0 8px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
color: var(--theme-2d2a26);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.provider-category-panel button:hover {
|
||||
background: var(--theme-f4f1ea);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.provider-category-panel button.active {
|
||||
border-color: var(--theme-d5d2cb);
|
||||
background: var(--theme-f0eee8);
|
||||
border-color: transparent;
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.provider-category-panel .provider-logo {
|
||||
@@ -7770,11 +7828,11 @@ textarea:focus-visible {
|
||||
/* Tab Switcher */
|
||||
.usage-tabs {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 8px;
|
||||
background: var(--theme-eeece6);
|
||||
gap: 3px;
|
||||
padding: 3px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
background: var(--bg-tertiary);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@@ -7797,24 +7855,24 @@ textarea:focus-visible {
|
||||
}
|
||||
|
||||
.usage-tabs button:hover:not(.active) {
|
||||
color: var(--theme-2d2a26);
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
color: var(--text-primary);
|
||||
background: color-mix(in srgb, var(--bg-primary) 70%, transparent);
|
||||
}
|
||||
|
||||
.usage-tabs button.active {
|
||||
background: var(--theme-fffdf8);
|
||||
color: var(--theme-2d2a26);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
/* Filter Toolbar */
|
||||
.usage-filter-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--theme-e3e1db);
|
||||
border-radius: 9px;
|
||||
background: var(--theme-fffdf8);
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.usage-filter-row {
|
||||
@@ -8082,27 +8140,6 @@ textarea:focus-visible {
|
||||
color: var(--theme-2d2a26);
|
||||
}
|
||||
|
||||
.usage-trend-legend-item.is-star {
|
||||
color: var(--theme-2d2a26);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.usage-trend-legend-item em {
|
||||
font-style: normal;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
background: color-mix(in srgb, #3b82f6 12%, transparent);
|
||||
color: #2563eb;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .usage-trend-legend-item em {
|
||||
color: #93c5fd;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.usage-trend-legend-item.is-hidden {
|
||||
opacity: 0.38;
|
||||
text-decoration: line-through;
|
||||
@@ -8137,7 +8174,7 @@ textarea:focus-visible {
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
grid-template-columns: 36px minmax(0, 1fr);
|
||||
grid-template-rows: minmax(140px, 1fr) 18px;
|
||||
grid-template-rows: minmax(140px, 1fr) 28px;
|
||||
column-gap: 8px;
|
||||
row-gap: 4px;
|
||||
}
|
||||
@@ -8222,7 +8259,7 @@ textarea:focus-visible {
|
||||
position: relative;
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
height: 18px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.usage-trend-x-axis span {
|
||||
@@ -8246,6 +8283,10 @@ textarea:focus-visible {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.usage-trend-x-axis span:nth-child(even) {
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
.usage-trend-tooltip {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
@@ -8316,22 +8357,6 @@ textarea:focus-visible {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.usage-trend-tooltip-label em {
|
||||
margin-left: 4px;
|
||||
font-style: normal;
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
border-radius: 3px;
|
||||
background: color-mix(in srgb, #3b82f6 14%, transparent);
|
||||
color: #2563eb;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:root[data-theme='dark'] .usage-trend-tooltip-label em {
|
||||
color: #93c5fd;
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
.usage-trend-tooltip-row b {
|
||||
color: var(--theme-2d2a26);
|
||||
font-variant-numeric: tabular-nums;
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ export type { AppTheme, ThemePreference } from './themeController';
|
||||
const STORAGE_KEY = 'easy-cli-proxy-api.theme';
|
||||
const WINDOW_BACKGROUND: Record<AppTheme, string> = {
|
||||
light: '#f6f7f5',
|
||||
dark: '#111412',
|
||||
dark: '#0b0d11',
|
||||
};
|
||||
|
||||
export function detectThemePreference(): ThemePreference {
|
||||
|
||||
+31
-21
@@ -39,13 +39,16 @@ describe('usage trend helpers', () => {
|
||||
expect(date?.getMonth()).toBe(8);
|
||||
expect(date?.getDate()).toBe(14);
|
||||
expect(date?.getHours()).toBe(15);
|
||||
expect(formatLocalHourKey(date as Date)).toBe('2026-09-14-15');
|
||||
expect(date?.getMinutes()).toBe(0);
|
||||
expect(formatLocalHourKey(date as Date)).toBe('2026-09-14-15-00');
|
||||
expect(parseLocalHourKey('2026-09-14-15-30')?.getMinutes()).toBe(30);
|
||||
expect(parseLocalHourKey('2026-02-30-10')).toBeNull();
|
||||
});
|
||||
|
||||
test('chooses coarser buckets as the range grows', () => {
|
||||
const hourStart = new Date(2026, 8, 14, 12, 0, 0);
|
||||
expect(chooseTrendBucket(hourStart, new Date(2026, 8, 15, 12, 0, 0))).toBe('hour');
|
||||
expect(chooseTrendBucket(hourStart, new Date(2026, 8, 15, 12, 0, 0))).toBe('30m');
|
||||
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 8, 4, 0, 0, 0))).toBe('hour');
|
||||
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 8, 8, 0, 0, 0))).toBe('3h');
|
||||
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 8, 16, 0, 0, 0))).toBe('day');
|
||||
expect(chooseTrendBucket(new Date(2026, 8, 1, 0, 0, 0), new Date(2026, 9, 1, 0, 0, 0))).toBe('day');
|
||||
@@ -65,17 +68,21 @@ describe('usage trend helpers', () => {
|
||||
{ start: start.toISOString(), end: end.toISOString() },
|
||||
);
|
||||
|
||||
expect(series.bucket).toBe('hour');
|
||||
expect(series.bucket).toBe('30m');
|
||||
expect(series.points.map((item) => item.hour)).toEqual([
|
||||
'2026-09-14-10',
|
||||
'2026-09-14-11',
|
||||
'2026-09-14-12',
|
||||
'2026-09-14-13',
|
||||
'2026-09-14-10-00',
|
||||
'2026-09-14-10-30',
|
||||
'2026-09-14-11-00',
|
||||
'2026-09-14-11-30',
|
||||
'2026-09-14-12-00',
|
||||
'2026-09-14-12-30',
|
||||
'2026-09-14-13-00',
|
||||
'2026-09-14-13-30',
|
||||
]);
|
||||
expect(series.points[1]).toMatchObject({ requests: 0, tokens: 0 });
|
||||
expect(series.points[2]).toMatchObject({ requests: 1, failure: 1, tokens: 10 });
|
||||
expect(series.points[4]).toMatchObject({ requests: 1, failure: 1, tokens: 10 });
|
||||
expect(series.totals).toMatchObject({ requests: 3, tokens: 30, failures: 1 });
|
||||
expect(series.peak?.hour).toBe('2026-09-14-10');
|
||||
expect(series.peak?.hour).toBe('2026-09-14-10-00');
|
||||
expect(series.peak?.tokens).toBe(20);
|
||||
});
|
||||
|
||||
@@ -87,15 +94,19 @@ describe('usage trend helpers', () => {
|
||||
{ start: start.toISOString(), end: end.toISOString() },
|
||||
);
|
||||
|
||||
expect(series.bucket).toBe('hour');
|
||||
expect(series.bucket).toBe('30m');
|
||||
expect(series.points.map((item) => item.hour)).toEqual([
|
||||
'2026-09-14-08',
|
||||
'2026-09-14-09',
|
||||
'2026-09-14-10',
|
||||
'2026-09-14-11',
|
||||
'2026-09-14-08-00',
|
||||
'2026-09-14-08-30',
|
||||
'2026-09-14-09-00',
|
||||
'2026-09-14-09-30',
|
||||
'2026-09-14-10-00',
|
||||
'2026-09-14-10-30',
|
||||
'2026-09-14-11-00',
|
||||
'2026-09-14-11-30',
|
||||
]);
|
||||
expect(series.points[0]).toMatchObject({ requests: 0, tokens: 0 });
|
||||
expect(series.points[2]).toMatchObject({ requests: 2, tokens: 20 });
|
||||
expect(series.points[4]).toMatchObject({ requests: 2, tokens: 20 });
|
||||
});
|
||||
|
||||
test('aggregates sparse hours into 3-hour buckets', () => {
|
||||
@@ -175,8 +186,6 @@ describe('usage trend helpers', () => {
|
||||
'model-5',
|
||||
OTHER_TREND_MODEL_KEY,
|
||||
]);
|
||||
expect(series.models[0].star).toBe(true);
|
||||
expect(series.models[1].star).toBe(false);
|
||||
expect(series.models[0].color).not.toBe(series.models[1].color);
|
||||
const stacked = stackModelTokens(series.points[0], series.models);
|
||||
expect(stacked[0]).toMatchObject({ key: 'model-0', tokens: 80, y0: 0, y1: 80 });
|
||||
@@ -207,10 +216,11 @@ describe('usage trend helpers', () => {
|
||||
test('builds readable axis ticks and labels', () => {
|
||||
expect(niceCeiling(0)).toBe(1);
|
||||
expect(niceCeiling(12)).toBe(20);
|
||||
expect(trendAxisTicks(20)).toEqual([0, 10, 20]);
|
||||
expect(selectTrendAxisLabels(24, 6)[0]).toBe(0);
|
||||
expect(selectTrendAxisLabels(24, 6).at(-1)).toBe(23);
|
||||
expect(selectTrendAxisLabels(24, 6)).toHaveLength(6);
|
||||
expect(trendAxisTicks(20)).toEqual([0, 5, 10, 15, 20]);
|
||||
expect(trendAxisTicks(5)).toEqual([0, 1, 2, 3, 4, 5]);
|
||||
expect(selectTrendAxisLabels(24, 8)[0]).toBe(0);
|
||||
expect(selectTrendAxisLabels(24, 8).at(-1)).toBe(23);
|
||||
expect(selectTrendAxisLabels(24, 8).length).toBeGreaterThanOrEqual(7);
|
||||
|
||||
const hourPoint = {
|
||||
...point('2026-09-14-15', 1, 1),
|
||||
|
||||
Reference in new issue
Block a user