token-larper 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +334 -0
- package/Start-TokenLarper.vbs +5 -0
- package/bin/cli.js +34 -0
- package/llms.txt +59 -0
- package/package.json +61 -0
- package/scripts/docs-demo-server.ts +141 -0
- package/scripts/launch-on-default-desktop.ps1 +42 -0
- package/scripts/launch-silent.vbs +6 -0
- package/scripts/run-server.ps1 +59 -0
- package/scripts/tray-host.ps1 +181 -0
- package/src/ccusage.ts +1075 -0
- package/src/client/App.tsx +560 -0
- package/src/client/charts.tsx +341 -0
- package/src/client/components/Calendar.tsx +207 -0
- package/src/client/components/DateRangePicker.tsx +131 -0
- package/src/client/components/KpiCards.tsx +122 -0
- package/src/client/components/RankChip.tsx +22 -0
- package/src/client/components/SelectMenu.tsx +116 -0
- package/src/client/components/SettingsDialog.tsx +207 -0
- package/src/client/components/ThemeSettings.tsx +131 -0
- package/src/client/context.tsx +60 -0
- package/src/client/index.html +54 -0
- package/src/client/lib/aggregate.ts +266 -0
- package/src/client/lib/range.ts +87 -0
- package/src/client/lib/rank.ts +323 -0
- package/src/client/lib/shareCard.ts +139 -0
- package/src/client/logo.svg +8 -0
- package/src/client/main.tsx +12 -0
- package/src/client/styles.css +1113 -0
- package/src/client/themes.ts +268 -0
- package/src/client/utils.ts +61 -0
- package/src/client/views/Models.tsx +71 -0
- package/src/client/views/Overview.tsx +280 -0
- package/src/client/views/Projects.tsx +88 -0
- package/src/client/views/Rank.tsx +255 -0
- package/src/client/views/Sessions.tsx +108 -0
- package/src/client/views/Tools.tsx +114 -0
- package/src/projects.ts +214 -0
- package/src/server.ts +173 -0
- package/src/startup.ts +110 -0
- package/src/tray.ts +293 -0
- package/src/types.ts +155 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
export type ThemeMode = "light" | "dark";
|
|
2
|
+
export type ThemePalette = "pine" | "slate" | "ocean" | "iris" | "clay" | "rose" | "custom";
|
|
3
|
+
|
|
4
|
+
export interface ThemePreset {
|
|
5
|
+
id: Exclude<ThemePalette, "custom">;
|
|
6
|
+
name: string;
|
|
7
|
+
description: string;
|
|
8
|
+
accent: string;
|
|
9
|
+
darkAccent: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const THEME_PRESETS: ThemePreset[] = [
|
|
13
|
+
{ id: "pine", name: "Pine", description: "The original forest green", accent: "#2f5a43", darkAccent: "#9fcaa8" },
|
|
14
|
+
{ id: "slate", name: "Slate", description: "Quiet blue gray", accent: "#495d75", darkAccent: "#9bb4d2" },
|
|
15
|
+
{ id: "ocean", name: "Ocean", description: "Cool blue and teal", accent: "#17627a", darkAccent: "#78c5d8" },
|
|
16
|
+
{ id: "iris", name: "Iris", description: "Soft violet", accent: "#68518f", darkAccent: "#c0a4ec" },
|
|
17
|
+
{ id: "clay", name: "Clay", description: "Warm terracotta", accent: "#995431", darkAccent: "#e1a177" },
|
|
18
|
+
{ id: "rose", name: "Rose", description: "Muted berry", accent: "#934f67", darkAccent: "#dfa1b4" },
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export const THEME_MODE_STORAGE_KEY = "token-larper-theme";
|
|
22
|
+
export const THEME_PALETTE_STORAGE_KEY = "token-larper-palette";
|
|
23
|
+
export const IMPORTED_THEME_STORAGE_KEY = "token-larper-imported-theme";
|
|
24
|
+
|
|
25
|
+
const APP_COLOR_PROPERTIES = [
|
|
26
|
+
"--bg", "--surface", "--surface-2", "--surface-3", "--border", "--border-strong",
|
|
27
|
+
"--text", "--text-2", "--text-3", "--accent", "--accent-ink", "--accent-soft", "--gold", "--focus",
|
|
28
|
+
"--warning-bg", "--warning-border", "--warning-text", "--error-bg", "--error-border", "--error-text",
|
|
29
|
+
"--series-1", "--series-2", "--series-3", "--series-4", "--series-5", "--series-6", "--series-7",
|
|
30
|
+
"--series-other", "--heat-0", "--heat-1", "--heat-2", "--heat-3", "--heat-4",
|
|
31
|
+
] as const;
|
|
32
|
+
const APP_RADIUS_PROPERTIES = ["--radius", "--radius-sm"] as const;
|
|
33
|
+
|
|
34
|
+
export type AppColorProperty = (typeof APP_COLOR_PROPERTIES)[number];
|
|
35
|
+
export type AppRadiusProperty = (typeof APP_RADIUS_PROPERTIES)[number];
|
|
36
|
+
export type AppThemeTokens = Partial<Record<AppColorProperty | AppRadiusProperty, string>>;
|
|
37
|
+
|
|
38
|
+
export interface ImportedTheme {
|
|
39
|
+
light: AppThemeTokens;
|
|
40
|
+
dark: AppThemeTokens;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const REQUIRED_PROPERTIES: AppColorProperty[] = ["--bg", "--surface", "--text", "--accent", "--accent-ink"];
|
|
44
|
+
|
|
45
|
+
export function isThemePalette(value: string | null): value is ThemePalette {
|
|
46
|
+
return value === "custom" || THEME_PRESETS.some((preset) => preset.id === value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function validColor(value: unknown): value is string {
|
|
50
|
+
return typeof value === "string"
|
|
51
|
+
&& value.length <= 180
|
|
52
|
+
&& !/[;{}]|\bvar\s*\(|url\s*\(/i.test(value)
|
|
53
|
+
&& CSS.supports("color", value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function validRadius(value: unknown): value is string {
|
|
57
|
+
return typeof value === "string" && /^\d+(?:\.\d+)?(?:px|rem|em)$/.test(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sanitizeTokens(value: unknown): AppThemeTokens | null {
|
|
61
|
+
if (!value || typeof value !== "object") return null;
|
|
62
|
+
const source = value as Record<string, unknown>;
|
|
63
|
+
const tokens: AppThemeTokens = {};
|
|
64
|
+
for (const property of APP_COLOR_PROPERTIES) {
|
|
65
|
+
if (validColor(source[property])) tokens[property] = source[property];
|
|
66
|
+
}
|
|
67
|
+
for (const property of APP_RADIUS_PROPERTIES) {
|
|
68
|
+
if (validRadius(source[property])) tokens[property] = source[property];
|
|
69
|
+
}
|
|
70
|
+
return REQUIRED_PROPERTIES.every((property) => tokens[property]) ? tokens : null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function sanitizeImportedTheme(value: unknown): ImportedTheme | null {
|
|
74
|
+
if (!value || typeof value !== "object") return null;
|
|
75
|
+
const source = value as Record<string, unknown>;
|
|
76
|
+
const light = sanitizeTokens(source.light);
|
|
77
|
+
const dark = sanitizeTokens(source.dark);
|
|
78
|
+
return light && dark ? { light, dark } : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function readImportedTheme(): ImportedTheme | null {
|
|
82
|
+
try {
|
|
83
|
+
return sanitizeImportedTheme(JSON.parse(localStorage.getItem(IMPORTED_THEME_STORAGE_KEY) || "null"));
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function readThemePalette(): ThemePalette {
|
|
90
|
+
try {
|
|
91
|
+
const stored = localStorage.getItem(THEME_PALETTE_STORAGE_KEY);
|
|
92
|
+
if (!isThemePalette(stored)) return "pine";
|
|
93
|
+
return stored === "custom" && !readImportedTheme() ? "pine" : stored;
|
|
94
|
+
} catch {
|
|
95
|
+
return "pine";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function applyTheme(mode: ThemeMode, palette: ThemePalette, importedTheme: ImportedTheme | null): void {
|
|
100
|
+
const root = document.documentElement;
|
|
101
|
+
root.dataset.theme = mode;
|
|
102
|
+
root.dataset.palette = palette;
|
|
103
|
+
for (const property of APP_COLOR_PROPERTIES) root.style.removeProperty(property);
|
|
104
|
+
for (const property of APP_RADIUS_PROPERTIES) root.style.removeProperty(property);
|
|
105
|
+
|
|
106
|
+
if (palette !== "custom" || !importedTheme) return;
|
|
107
|
+
const tokens = importedTheme[mode];
|
|
108
|
+
for (const property of APP_COLOR_PROPERTIES) {
|
|
109
|
+
const value = tokens[property];
|
|
110
|
+
if (validColor(value)) root.style.setProperty(property, value);
|
|
111
|
+
}
|
|
112
|
+
for (const property of APP_RADIUS_PROPERTIES) {
|
|
113
|
+
if (validRadius(tokens[property])) root.style.setProperty(property, tokens[property]);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseColorBlocks(css: string): { light: Map<string, string>; dark: Map<string, string> } {
|
|
118
|
+
if (css.length > 50_000) throw new Error("Theme CSS is too large. Keep it under 50 KB.");
|
|
119
|
+
const cleaned = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
120
|
+
const light = new Map<string, string>();
|
|
121
|
+
const dark = new Map<string, string>();
|
|
122
|
+
const blocks = /([^{}]+)\{([^{}]*)\}/g;
|
|
123
|
+
let match: RegExpExecArray | null;
|
|
124
|
+
|
|
125
|
+
while ((match = blocks.exec(cleaned))) {
|
|
126
|
+
const selector = match[1]!.trim();
|
|
127
|
+
const isDark = /\.dark\b|data-(?:theme|mode)\s*=\s*(?:["']dark["']|dark\b)/i.test(selector);
|
|
128
|
+
const isLight = /:root\b|^html\b|data-(?:theme|mode)\s*=\s*(?:["']light["']|light\b)/i.test(selector);
|
|
129
|
+
if (!isDark && !isLight) continue;
|
|
130
|
+
|
|
131
|
+
const destination = isDark ? dark : light;
|
|
132
|
+
for (const declaration of match[2]!.split(";")) {
|
|
133
|
+
const colon = declaration.indexOf(":");
|
|
134
|
+
if (colon < 0) continue;
|
|
135
|
+
const name = declaration.slice(0, colon).trim();
|
|
136
|
+
const value = declaration.slice(colon + 1).trim().replace(/\s*!important\s*$/i, "");
|
|
137
|
+
if (name.startsWith("--") && value) destination.set(name.slice(2), value);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { light, dark };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function colorValue(tokens: Map<string, string>, key: string, fallback?: string): string {
|
|
145
|
+
const raw = tokens.get(key) ?? fallback;
|
|
146
|
+
if (!raw) throw new Error(`The pasted CSS is missing --${key}.`);
|
|
147
|
+
// Older shadcn exports store HSL channels without the hsl() wrapper.
|
|
148
|
+
const value = /^-?\d+(?:\.\d+)?(?:deg|turn|rad)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%(?:\s*\/\s*\d+(?:\.\d+)?%?)?$/i.test(raw)
|
|
149
|
+
? `hsl(${raw})`
|
|
150
|
+
: raw;
|
|
151
|
+
if (!validColor(value)) throw new Error(`--${key} must be a CSS color such as oklch(...), hsl(...), rgb(...), or #hex.`);
|
|
152
|
+
return value;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function mapShadcnTokens(source: Map<string, string>, fallback?: Map<string, string>): AppThemeTokens {
|
|
156
|
+
const get = (key: string, appFallback?: string) => colorValue(source, key, appFallback);
|
|
157
|
+
const background = get("background");
|
|
158
|
+
const foreground = get("foreground");
|
|
159
|
+
const primary = get("primary");
|
|
160
|
+
const primaryForeground = get("primary-foreground");
|
|
161
|
+
const mutedForeground = get("muted-foreground", foreground);
|
|
162
|
+
const secondary = get("secondary", source.get("muted") ?? background);
|
|
163
|
+
const muted = get("muted", secondary);
|
|
164
|
+
const destructive = source.has("destructive") ? get("destructive") : "#a9342b";
|
|
165
|
+
const tokens: AppThemeTokens = {
|
|
166
|
+
"--bg": background,
|
|
167
|
+
"--surface": get("card", source.get("popover") ?? background),
|
|
168
|
+
"--surface-2": muted,
|
|
169
|
+
"--surface-3": secondary,
|
|
170
|
+
"--border": source.has("border") ? get("border") : `color-mix(in srgb, ${foreground} 14%, ${background})`,
|
|
171
|
+
"--border-strong": source.has("input") ? get("input") : get("border", `color-mix(in srgb, ${foreground} 22%, ${background})`),
|
|
172
|
+
"--text": foreground,
|
|
173
|
+
"--text-2": mutedForeground,
|
|
174
|
+
"--text-3": `color-mix(in srgb, ${mutedForeground} 78%, ${background})`,
|
|
175
|
+
"--accent": primary,
|
|
176
|
+
"--accent-ink": primaryForeground,
|
|
177
|
+
"--accent-soft": source.has("accent") ? get("accent") : secondary,
|
|
178
|
+
"--focus": source.has("ring") ? get("ring") : primary,
|
|
179
|
+
"--error-bg": `color-mix(in srgb, ${background} 90%, ${destructive})`,
|
|
180
|
+
"--error-border": destructive,
|
|
181
|
+
"--error-text": destructive,
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
for (let index = 1; index <= 7; index++) {
|
|
185
|
+
const key = `chart-${index}`;
|
|
186
|
+
if (source.has(key)) tokens[`--series-${index}` as AppColorProperty] = get(key);
|
|
187
|
+
}
|
|
188
|
+
for (const property of APP_RADIUS_PROPERTIES) {
|
|
189
|
+
const key = property.slice(2);
|
|
190
|
+
const radius = source.get(key) ?? fallback?.get(key);
|
|
191
|
+
if (validRadius(radius)) tokens[property] = radius;
|
|
192
|
+
}
|
|
193
|
+
return tokens;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function mapTokenLarperTokens(source: Map<string, string>, fallback?: Map<string, string>): AppThemeTokens {
|
|
197
|
+
const background = colorValue(source, "bg");
|
|
198
|
+
const surface = colorValue(source, "surface");
|
|
199
|
+
const foreground = colorValue(source, "text");
|
|
200
|
+
const accent = colorValue(source, "accent");
|
|
201
|
+
const accentInk = colorValue(source, "accent-ink");
|
|
202
|
+
const tokens: AppThemeTokens = {
|
|
203
|
+
"--bg": background,
|
|
204
|
+
"--surface": surface,
|
|
205
|
+
"--surface-2": `color-mix(in srgb, ${surface} 94%, ${foreground})`,
|
|
206
|
+
"--surface-3": `color-mix(in srgb, ${surface} 88%, ${foreground})`,
|
|
207
|
+
"--border": `color-mix(in srgb, ${surface} 84%, ${foreground})`,
|
|
208
|
+
"--border-strong": `color-mix(in srgb, ${surface} 72%, ${foreground})`,
|
|
209
|
+
"--text": foreground,
|
|
210
|
+
"--text-2": `color-mix(in srgb, ${foreground} 75%, ${background})`,
|
|
211
|
+
"--text-3": `color-mix(in srgb, ${foreground} 70%, ${background})`,
|
|
212
|
+
"--accent": accent,
|
|
213
|
+
"--accent-ink": accentInk,
|
|
214
|
+
"--accent-soft": `color-mix(in srgb, ${background} 86%, ${accent})`,
|
|
215
|
+
"--focus": accent,
|
|
216
|
+
};
|
|
217
|
+
for (const property of APP_COLOR_PROPERTIES) {
|
|
218
|
+
const key = property.slice(2);
|
|
219
|
+
if (source.has(key)) tokens[property] = colorValue(source, key);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
for (const property of APP_RADIUS_PROPERTIES) {
|
|
223
|
+
const key = property.slice(2);
|
|
224
|
+
const radius = source.get(key) ?? fallback?.get(key);
|
|
225
|
+
if (validRadius(radius)) tokens[property] = radius;
|
|
226
|
+
}
|
|
227
|
+
return tokens;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const REQUIRED_SHADCN_PROPERTIES = ["background", "foreground", "primary", "primary-foreground"];
|
|
231
|
+
const REQUIRED_APP_PROPERTIES = REQUIRED_PROPERTIES.map((property) => property.slice(2));
|
|
232
|
+
|
|
233
|
+
export function importThemeCss(css: string): ImportedTheme {
|
|
234
|
+
if (typeof css !== "string" || !css.trim()) {
|
|
235
|
+
throw new Error("Paste CSS with light and dark color variable blocks.");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const blocks = parseColorBlocks(css);
|
|
239
|
+
if (blocks.light.size === 0 || blocks.dark.size === 0) {
|
|
240
|
+
throw new Error("Include a light :root block and a dark .dark or [data-theme=\"dark\"] block.");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const hasAppTokens = [blocks.light, blocks.dark].every((tokens) =>
|
|
244
|
+
REQUIRED_APP_PROPERTIES.every((key) => tokens.has(key))
|
|
245
|
+
);
|
|
246
|
+
const hasShadcnTokens = [blocks.light, blocks.dark].every((tokens) =>
|
|
247
|
+
REQUIRED_SHADCN_PROPERTIES.every((key) => tokens.has(key))
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
let light: AppThemeTokens;
|
|
251
|
+
let dark: AppThemeTokens;
|
|
252
|
+
if (hasAppTokens) {
|
|
253
|
+
light = mapTokenLarperTokens(blocks.light);
|
|
254
|
+
dark = mapTokenLarperTokens(blocks.dark, blocks.light);
|
|
255
|
+
} else if (hasShadcnTokens) {
|
|
256
|
+
light = mapShadcnTokens(blocks.light);
|
|
257
|
+
dark = mapShadcnTokens(blocks.dark, blocks.light);
|
|
258
|
+
} else {
|
|
259
|
+
throw new Error(
|
|
260
|
+
"Both light and dark blocks need --bg, --surface, --text, --accent, and --accent-ink. "
|
|
261
|
+
+ "shadcn-style --background, --foreground, --primary, and --primary-foreground are supported too."
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const sanitized = sanitizeImportedTheme({ light, dark });
|
|
266
|
+
if (!sanitized) throw new Error("Some theme values are not valid CSS colors. Check the required tokens in both blocks.");
|
|
267
|
+
return sanitized;
|
|
268
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
export function formatCompactNumber(n: number): string {
|
|
2
|
+
if (!Number.isFinite(n)) return "0";
|
|
3
|
+
const abs = Math.abs(n);
|
|
4
|
+
if (abs >= 1_000_000_000) {
|
|
5
|
+
return `${(n / 1_000_000_000).toFixed(2)}B`;
|
|
6
|
+
}
|
|
7
|
+
if (abs >= 1_000_000) {
|
|
8
|
+
return `${(n / 1_000_000).toFixed(2)}M`;
|
|
9
|
+
}
|
|
10
|
+
if (abs >= 1_000) {
|
|
11
|
+
return `${(n / 1_000).toFixed(1)}K`;
|
|
12
|
+
}
|
|
13
|
+
return Math.round(n).toLocaleString("en-US");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** YYYY-MM-DD for a Date in the local time zone. */
|
|
17
|
+
export function localDateKey(d: Date): string {
|
|
18
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** ISO-8601 week for a calendar date: weeks start Monday, week 1 holds the year's first Thursday. */
|
|
22
|
+
export function isoWeek(year: number, month: number, day: number): { key: string; label: string } {
|
|
23
|
+
const d = new Date(Date.UTC(year, month - 1, day));
|
|
24
|
+
const dayNum = d.getUTCDay() || 7;
|
|
25
|
+
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
|
26
|
+
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
|
27
|
+
const weekNo = Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
|
28
|
+
const isoYear = d.getUTCFullYear();
|
|
29
|
+
return { key: `${isoYear}-W${String(weekNo).padStart(2, "0")}`, label: `Week ${weekNo}, ${isoYear}` };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatExactNumber(n: number): string {
|
|
33
|
+
if (!Number.isFinite(n)) return "0";
|
|
34
|
+
return Math.round(n).toLocaleString("en-US");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatCurrency(n: number, decimals = 2): string {
|
|
38
|
+
if (!Number.isFinite(n)) return "$0.00";
|
|
39
|
+
if (n > 0 && n < 0.01 && decimals === 2) {
|
|
40
|
+
return `$${n.toFixed(4)}`;
|
|
41
|
+
}
|
|
42
|
+
return new Intl.NumberFormat("en-US", {
|
|
43
|
+
style: "currency",
|
|
44
|
+
currency: "USD",
|
|
45
|
+
minimumFractionDigits: decimals,
|
|
46
|
+
maximumFractionDigits: decimals,
|
|
47
|
+
}).format(n);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function cleanProjectPath(raw: string): string {
|
|
51
|
+
if (!raw) return "Unknown Session";
|
|
52
|
+
// Convert Claude/Pi encoded paths like "F--Just-Some-Files-opensource-twtbk" or "--F--Just Some Files-..."
|
|
53
|
+
let cleaned = raw.replace(/^--+|--+$/g, "");
|
|
54
|
+
if (/^[A-Za-z]--/.test(cleaned)) {
|
|
55
|
+
cleaned = `${cleaned[0]}:/${cleaned.slice(3).replace(/-/g, "/")}`;
|
|
56
|
+
}
|
|
57
|
+
if (cleaned.length > 58) {
|
|
58
|
+
return "..." + cleaned.slice(-55);
|
|
59
|
+
}
|
|
60
|
+
return cleaned;
|
|
61
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { ShareBar, ToolTag, pct, useDashboard } from "../context.tsx";
|
|
3
|
+
import { modelTotals } from "../lib/aggregate.ts";
|
|
4
|
+
import { formatCompactNumber, formatCurrency } from "../utils.ts";
|
|
5
|
+
|
|
6
|
+
export function Models() {
|
|
7
|
+
const { days, costOf, estimated, search, seriesOf } = useDashboard();
|
|
8
|
+
const all = useMemo(() => modelTotals(days), [days]);
|
|
9
|
+
const total = all.reduce((acc, m) => acc + m.totalTokens, 0);
|
|
10
|
+
const q = search.trim().toLowerCase();
|
|
11
|
+
const models = q ? all.filter((m) => m.modelName.toLowerCase().includes(q) || m.harness.includes(q)) : all;
|
|
12
|
+
const max = all[0]?.totalTokens ?? 0;
|
|
13
|
+
const unpriced = models.filter((m) => m.missingPricing).length;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<section className="panel">
|
|
17
|
+
<header className="panel-head">
|
|
18
|
+
<div>
|
|
19
|
+
<h2>Models</h2>
|
|
20
|
+
<p>
|
|
21
|
+
{models.length} {models.length === 1 ? "model" : "models"}
|
|
22
|
+
{q ? ` matching “${search.trim()}”` : ""}.
|
|
23
|
+
{unpriced > 0 && ` ${unpriced} have no verified price; "Estimated API value" fills those in.`}
|
|
24
|
+
</p>
|
|
25
|
+
</div>
|
|
26
|
+
</header>
|
|
27
|
+
<div className="table-scroll">
|
|
28
|
+
<table className="table">
|
|
29
|
+
<thead>
|
|
30
|
+
<tr>
|
|
31
|
+
<th>Model</th>
|
|
32
|
+
<th>Tool</th>
|
|
33
|
+
<th className="bar-col"><span className="sr-only">Share</span></th>
|
|
34
|
+
<th className="num">Input</th>
|
|
35
|
+
<th className="num">Output</th>
|
|
36
|
+
<th className="num">Cache read</th>
|
|
37
|
+
<th className="num">Cache write</th>
|
|
38
|
+
<th className="num">Total</th>
|
|
39
|
+
<th className="num">Share</th>
|
|
40
|
+
<th className="num">{estimated ? "Est. value" : "Cost"}</th>
|
|
41
|
+
</tr>
|
|
42
|
+
</thead>
|
|
43
|
+
<tbody>
|
|
44
|
+
{models.map((m) => (
|
|
45
|
+
<tr key={`${m.harness}-${m.modelName}`}>
|
|
46
|
+
<td>
|
|
47
|
+
<code className="model-name">{m.modelName}</code>
|
|
48
|
+
{m.missingPricing && (
|
|
49
|
+
<span className="tag" title="Not in ccusage's price table. Token Larper estimates its value.">
|
|
50
|
+
Unpriced
|
|
51
|
+
</span>
|
|
52
|
+
)}
|
|
53
|
+
</td>
|
|
54
|
+
<td><ToolTag id={m.harness} /></td>
|
|
55
|
+
<td className="bar-col"><ShareBar value={m.totalTokens} max={max} color={seriesOf(m.harness).color} /></td>
|
|
56
|
+
<td className="num">{formatCompactNumber(m.inputTokens)}</td>
|
|
57
|
+
<td className="num">{formatCompactNumber(m.outputTokens)}</td>
|
|
58
|
+
<td className="num">{formatCompactNumber(m.cacheReadTokens)}</td>
|
|
59
|
+
<td className="num">{formatCompactNumber(m.cacheCreationTokens)}</td>
|
|
60
|
+
<td className="num strong">{formatCompactNumber(m.totalTokens)}</td>
|
|
61
|
+
<td className="num muted">{pct(m.totalTokens, total)}</td>
|
|
62
|
+
<td className="num">{formatCurrency(costOf(m))}</td>
|
|
63
|
+
</tr>
|
|
64
|
+
))}
|
|
65
|
+
</tbody>
|
|
66
|
+
</table>
|
|
67
|
+
{models.length === 0 && <p className="muted table-empty">No models match.</p>}
|
|
68
|
+
</div>
|
|
69
|
+
</section>
|
|
70
|
+
);
|
|
71
|
+
}
|