claude-session-viewer 0.3.4 → 0.3.6
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/bin/dev.js +20 -1
- package/dist/client/assets/index-Csvqk3mE.js +70 -0
- package/dist/client/assets/index-DOk7moPK.css +1 -0
- package/dist/client/index.html +2 -2
- package/dist/server/claude/projects/repository.js +1 -1
- package/dist/server/claude/projects/service.js +4 -4
- package/dist/server/claude/sessions/service.js +9 -5
- package/dist/server/routes/sessionWindows.js +127 -0
- package/dist/server/routes/sessions.js +5 -4
- package/dist/server/routes/statistics.js +34 -310
- package/dist/server/statistics/aggregator.js +290 -0
- package/dist/server/statistics/service.js +226 -0
- package/dist/server/statistics/tokenStats.js +66 -0
- package/dist/server/statistics/tokenUsage.js +66 -0
- package/dist/server/statistics/utils.js +33 -0
- package/dist/server/utils/sessionWindows.js +159 -0
- package/package.json +2 -2
- package/dist/client/assets/index-DvK33tag.css +0 -1
- package/dist/client/assets/index-KEbXAXOS.js +0 -69
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { PRICING, DEFAULT_PRICING_MODEL, WEEKDAY_NAMES } from '../claude/config.js';
|
|
2
|
+
import { aggregateTokenUsage, calculateCost } from './tokenUsage.js';
|
|
3
|
+
import { fillMissingDates } from './utils.js';
|
|
4
|
+
import { aggregateAllProjects, aggregateProject } from './aggregator.js';
|
|
5
|
+
/**
|
|
6
|
+
* Calculate cutoff date based on days parameter
|
|
7
|
+
*/
|
|
8
|
+
function calculateCutoffDate(days) {
|
|
9
|
+
const cutoffDate = new Date();
|
|
10
|
+
if (days === 'all') {
|
|
11
|
+
cutoffDate.setFullYear(2000, 0, 1);
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
const daysNum = typeof days === 'string' ? parseInt(days, 10) : days;
|
|
15
|
+
cutoffDate.setDate(cutoffDate.getDate() - daysNum);
|
|
16
|
+
cutoffDate.setHours(0, 0, 0, 0);
|
|
17
|
+
}
|
|
18
|
+
return cutoffDate;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Get overall usage statistics across all projects
|
|
22
|
+
*/
|
|
23
|
+
export async function getOverallStatistics(days = 7) {
|
|
24
|
+
const daysParam = days.toString();
|
|
25
|
+
const cutoffDate = calculateCutoffDate(days);
|
|
26
|
+
const data = await aggregateAllProjects(cutoffDate);
|
|
27
|
+
// Calculate totals
|
|
28
|
+
const totalUsage = aggregateTokenUsage(data.allUsages);
|
|
29
|
+
const defaultPricing = PRICING[DEFAULT_PRICING_MODEL];
|
|
30
|
+
const costBreakdown = calculateCost(totalUsage, defaultPricing);
|
|
31
|
+
const savedCost = (data.totalCacheRead / 1000000) * (defaultPricing.input - defaultPricing.cacheRead);
|
|
32
|
+
// Determine date range first (this drives all other date-based statistics)
|
|
33
|
+
const startDate = daysParam === 'all' && data.minDate
|
|
34
|
+
? data.minDate
|
|
35
|
+
: cutoffDate;
|
|
36
|
+
const endDate = new Date();
|
|
37
|
+
// Fill daily stats based on the determined date range
|
|
38
|
+
const daily = fillMissingDates(data.dailyMap, startDate, endDate);
|
|
39
|
+
// Build project stats
|
|
40
|
+
const byProject = Array.from(data.projectMap.entries())
|
|
41
|
+
.map(([project, projectData]) => ({
|
|
42
|
+
id: project,
|
|
43
|
+
name: projectData.name,
|
|
44
|
+
tokenUsage: projectData.tokenUsage,
|
|
45
|
+
sessionCount: projectData.sessionIds.size
|
|
46
|
+
}))
|
|
47
|
+
.sort((a, b) => b.tokenUsage.totalTokens - a.tokenUsage.totalTokens);
|
|
48
|
+
// Build model stats
|
|
49
|
+
const byModel = Array.from(data.modelMap.entries())
|
|
50
|
+
.map(([model, modelData]) => ({
|
|
51
|
+
model,
|
|
52
|
+
tokenUsage: modelData.tokenUsage,
|
|
53
|
+
messageCount: modelData.messageCount
|
|
54
|
+
}))
|
|
55
|
+
.sort((a, b) => b.tokenUsage.totalTokens - a.tokenUsage.totalTokens);
|
|
56
|
+
// Build tool usage stats
|
|
57
|
+
const toolUsage = Array.from(data.toolUsageMap.entries())
|
|
58
|
+
.map(([toolName, stats]) => ({
|
|
59
|
+
toolName,
|
|
60
|
+
totalUses: stats.total,
|
|
61
|
+
successfulUses: stats.successful,
|
|
62
|
+
successRate: stats.total > 0 ? (stats.successful / stats.total) * 100 : 0
|
|
63
|
+
}))
|
|
64
|
+
.sort((a, b) => b.totalUses - a.totalUses);
|
|
65
|
+
const totalToolCalls = Array.from(data.toolUsageMap.values()).reduce((sum, stats) => sum + stats.total, 0);
|
|
66
|
+
const agentUsageRate = data.totalSessions > 0 ? (data.totalAgentSessions / data.totalSessions) * 100 : 0;
|
|
67
|
+
// Build hourly stats
|
|
68
|
+
const byHour = Array.from(data.hourlyMap.entries())
|
|
69
|
+
.map(([hour, hourData]) => ({
|
|
70
|
+
hour,
|
|
71
|
+
sessionCount: hourData.sessionIds.size,
|
|
72
|
+
messageCount: hourData.messageCount,
|
|
73
|
+
tokenUsage: hourData.tokenUsage
|
|
74
|
+
}))
|
|
75
|
+
.sort((a, b) => a.hour - b.hour);
|
|
76
|
+
// Build weekday stats
|
|
77
|
+
const byWeekday = Array.from(data.weekdayMap.entries())
|
|
78
|
+
.map(([weekday, weekdayData]) => ({
|
|
79
|
+
weekday,
|
|
80
|
+
weekdayName: WEEKDAY_NAMES[weekday],
|
|
81
|
+
sessionCount: weekdayData.sessionIds.size,
|
|
82
|
+
messageCount: weekdayData.messageCount,
|
|
83
|
+
tokenUsage: weekdayData.tokenUsage
|
|
84
|
+
}))
|
|
85
|
+
.sort((a, b) => a.weekday - b.weekday);
|
|
86
|
+
const statistics = {
|
|
87
|
+
overview: {
|
|
88
|
+
tokenUsage: totalUsage,
|
|
89
|
+
sessionCount: data.totalSessions,
|
|
90
|
+
messageCount: data.totalMessages,
|
|
91
|
+
dateRange: {
|
|
92
|
+
start: startDate?.toISOString() || endDate.toISOString(),
|
|
93
|
+
end: endDate.toISOString()
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
daily,
|
|
97
|
+
byProject,
|
|
98
|
+
byModel,
|
|
99
|
+
cache: {
|
|
100
|
+
totalCacheCreation: data.totalCacheCreation,
|
|
101
|
+
totalCacheRead: data.totalCacheRead,
|
|
102
|
+
ephemeral5mTokens: data.ephemeral5mTokens,
|
|
103
|
+
ephemeral1hTokens: data.ephemeral1hTokens,
|
|
104
|
+
cacheHitRate: (data.totalCacheCreation + data.totalCacheRead) > 0
|
|
105
|
+
? (data.totalCacheRead / (data.totalCacheCreation + data.totalCacheRead)) * 100
|
|
106
|
+
: 0,
|
|
107
|
+
estimatedSavings: savedCost
|
|
108
|
+
},
|
|
109
|
+
cost: costBreakdown,
|
|
110
|
+
productivity: {
|
|
111
|
+
toolUsage,
|
|
112
|
+
totalToolCalls,
|
|
113
|
+
agentSessions: data.totalAgentSessions,
|
|
114
|
+
totalSessions: data.totalSessions,
|
|
115
|
+
agentUsageRate
|
|
116
|
+
},
|
|
117
|
+
trends: {
|
|
118
|
+
byHour,
|
|
119
|
+
byWeekday
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
return statistics;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Get usage statistics for a specific project
|
|
126
|
+
*/
|
|
127
|
+
export async function getProjectStatistics(projectId, days = 7) {
|
|
128
|
+
const daysParam = days.toString();
|
|
129
|
+
const cutoffDate = calculateCutoffDate(days);
|
|
130
|
+
const data = await aggregateProject(projectId, cutoffDate);
|
|
131
|
+
// Calculate totals
|
|
132
|
+
const totalUsage = aggregateTokenUsage(data.allUsages);
|
|
133
|
+
const defaultPricing = PRICING[DEFAULT_PRICING_MODEL];
|
|
134
|
+
const costBreakdown = calculateCost(totalUsage, defaultPricing);
|
|
135
|
+
const savedCost = (data.totalCacheRead / 1000000) * (defaultPricing.input - defaultPricing.cacheRead);
|
|
136
|
+
// Determine date range first (this drives all other date-based statistics)
|
|
137
|
+
const startDate = daysParam === 'all' && data.minDate
|
|
138
|
+
? data.minDate
|
|
139
|
+
: cutoffDate;
|
|
140
|
+
const endDate = new Date();
|
|
141
|
+
// Fill daily stats based on the determined date range
|
|
142
|
+
const daily = fillMissingDates(data.dailyMap, startDate, endDate);
|
|
143
|
+
// Build project stats (single project)
|
|
144
|
+
const projectData = data.projectMap.get(projectId);
|
|
145
|
+
const byProject = projectData ? [{
|
|
146
|
+
id: projectId,
|
|
147
|
+
name: projectData.name,
|
|
148
|
+
tokenUsage: totalUsage,
|
|
149
|
+
sessionCount: data.totalSessions
|
|
150
|
+
}] : [];
|
|
151
|
+
// Build model stats
|
|
152
|
+
const byModel = Array.from(data.modelMap.entries())
|
|
153
|
+
.map(([model, modelData]) => ({
|
|
154
|
+
model,
|
|
155
|
+
tokenUsage: modelData.tokenUsage,
|
|
156
|
+
messageCount: modelData.messageCount
|
|
157
|
+
}))
|
|
158
|
+
.sort((a, b) => b.tokenUsage.totalTokens - a.tokenUsage.totalTokens);
|
|
159
|
+
// Build tool usage stats
|
|
160
|
+
const toolUsage = Array.from(data.toolUsageMap.entries())
|
|
161
|
+
.map(([toolName, stats]) => ({
|
|
162
|
+
toolName,
|
|
163
|
+
totalUses: stats.total,
|
|
164
|
+
successfulUses: stats.successful,
|
|
165
|
+
successRate: stats.total > 0 ? (stats.successful / stats.total) * 100 : 0
|
|
166
|
+
}))
|
|
167
|
+
.sort((a, b) => b.totalUses - a.totalUses);
|
|
168
|
+
const totalToolCalls = Array.from(data.toolUsageMap.values()).reduce((sum, stats) => sum + stats.total, 0);
|
|
169
|
+
const agentUsageRate = data.totalSessions > 0 ? (data.totalAgentSessions / data.totalSessions) * 100 : 0;
|
|
170
|
+
// Build hourly stats
|
|
171
|
+
const byHour = Array.from(data.hourlyMap.entries())
|
|
172
|
+
.map(([hour, hourData]) => ({
|
|
173
|
+
hour,
|
|
174
|
+
sessionCount: hourData.sessionIds.size,
|
|
175
|
+
messageCount: hourData.messageCount,
|
|
176
|
+
tokenUsage: hourData.tokenUsage
|
|
177
|
+
}))
|
|
178
|
+
.sort((a, b) => a.hour - b.hour);
|
|
179
|
+
// Build weekday stats
|
|
180
|
+
const byWeekday = Array.from(data.weekdayMap.entries())
|
|
181
|
+
.map(([weekday, weekdayData]) => ({
|
|
182
|
+
weekday,
|
|
183
|
+
weekdayName: WEEKDAY_NAMES[weekday],
|
|
184
|
+
sessionCount: weekdayData.sessionIds.size,
|
|
185
|
+
messageCount: weekdayData.messageCount,
|
|
186
|
+
tokenUsage: weekdayData.tokenUsage
|
|
187
|
+
}))
|
|
188
|
+
.sort((a, b) => a.weekday - b.weekday);
|
|
189
|
+
const statistics = {
|
|
190
|
+
overview: {
|
|
191
|
+
tokenUsage: totalUsage,
|
|
192
|
+
sessionCount: data.totalSessions,
|
|
193
|
+
messageCount: data.totalMessages,
|
|
194
|
+
dateRange: {
|
|
195
|
+
start: startDate?.toISOString() || endDate.toISOString(),
|
|
196
|
+
end: endDate.toISOString()
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
daily,
|
|
200
|
+
byProject,
|
|
201
|
+
byModel,
|
|
202
|
+
cache: {
|
|
203
|
+
totalCacheCreation: data.totalCacheCreation,
|
|
204
|
+
totalCacheRead: data.totalCacheRead,
|
|
205
|
+
ephemeral5mTokens: data.ephemeral5mTokens,
|
|
206
|
+
ephemeral1hTokens: data.ephemeral1hTokens,
|
|
207
|
+
cacheHitRate: (data.totalCacheCreation + data.totalCacheRead) > 0
|
|
208
|
+
? (data.totalCacheRead / (data.totalCacheCreation + data.totalCacheRead)) * 100
|
|
209
|
+
: 0,
|
|
210
|
+
estimatedSavings: savedCost
|
|
211
|
+
},
|
|
212
|
+
cost: costBreakdown,
|
|
213
|
+
productivity: {
|
|
214
|
+
toolUsage,
|
|
215
|
+
totalToolCalls,
|
|
216
|
+
agentSessions: data.totalAgentSessions,
|
|
217
|
+
totalSessions: data.totalSessions,
|
|
218
|
+
agentUsageRate
|
|
219
|
+
},
|
|
220
|
+
trends: {
|
|
221
|
+
byHour,
|
|
222
|
+
byWeekday
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
return statistics;
|
|
226
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token statistics utilities
|
|
3
|
+
* Pure calculation functions for token usage analysis
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Extract token usage from a message object
|
|
7
|
+
*/
|
|
8
|
+
export function extractTokenUsage(message) {
|
|
9
|
+
if (message.type !== 'assistant' || !message.message?.usage) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const usage = message.message.usage;
|
|
13
|
+
const model = message.message.model || 'unknown';
|
|
14
|
+
return {
|
|
15
|
+
usage: {
|
|
16
|
+
inputTokens: usage.input_tokens || 0,
|
|
17
|
+
cacheCreationTokens: usage.cache_creation_input_tokens || 0,
|
|
18
|
+
cacheReadTokens: usage.cache_read_input_tokens || 0,
|
|
19
|
+
outputTokens: usage.output_tokens || 0,
|
|
20
|
+
totalTokens: (usage.input_tokens || 0) +
|
|
21
|
+
(usage.cache_creation_input_tokens || 0) +
|
|
22
|
+
(usage.cache_read_input_tokens || 0) +
|
|
23
|
+
(usage.output_tokens || 0)
|
|
24
|
+
},
|
|
25
|
+
model
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Aggregate multiple token usages into a single total
|
|
30
|
+
*/
|
|
31
|
+
export function aggregateTokenUsage(usages) {
|
|
32
|
+
return usages.reduce((acc, usage) => ({
|
|
33
|
+
inputTokens: acc.inputTokens + usage.inputTokens,
|
|
34
|
+
cacheCreationTokens: acc.cacheCreationTokens + usage.cacheCreationTokens,
|
|
35
|
+
cacheReadTokens: acc.cacheReadTokens + usage.cacheReadTokens,
|
|
36
|
+
outputTokens: acc.outputTokens + usage.outputTokens,
|
|
37
|
+
totalTokens: acc.totalTokens + usage.totalTokens
|
|
38
|
+
}), createEmptyTokenUsage());
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Calculate cost for token usage based on pricing
|
|
42
|
+
*/
|
|
43
|
+
export function calculateCost(usage, pricing) {
|
|
44
|
+
return {
|
|
45
|
+
inputCost: (usage.inputTokens / 1000000) * pricing.input,
|
|
46
|
+
outputCost: (usage.outputTokens / 1000000) * pricing.output,
|
|
47
|
+
cacheCreationCost: (usage.cacheCreationTokens / 1000000) * pricing.cacheCreation,
|
|
48
|
+
cacheReadCost: (usage.cacheReadTokens / 1000000) * pricing.cacheRead,
|
|
49
|
+
totalCost: (usage.inputTokens / 1000000) * pricing.input +
|
|
50
|
+
(usage.outputTokens / 1000000) * pricing.output +
|
|
51
|
+
(usage.cacheCreationTokens / 1000000) * pricing.cacheCreation +
|
|
52
|
+
(usage.cacheReadTokens / 1000000) * pricing.cacheRead
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Create an empty token usage object
|
|
57
|
+
*/
|
|
58
|
+
export function createEmptyTokenUsage() {
|
|
59
|
+
return {
|
|
60
|
+
inputTokens: 0,
|
|
61
|
+
cacheCreationTokens: 0,
|
|
62
|
+
cacheReadTokens: 0,
|
|
63
|
+
outputTokens: 0,
|
|
64
|
+
totalTokens: 0
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token statistics utilities
|
|
3
|
+
* Pure calculation functions for token usage analysis
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Extract token usage from a message object
|
|
7
|
+
*/
|
|
8
|
+
export function extractTokenUsage(message) {
|
|
9
|
+
if (message.type !== 'assistant' || !message.message?.usage) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const usage = message.message.usage;
|
|
13
|
+
const model = message.message.model || 'unknown';
|
|
14
|
+
return {
|
|
15
|
+
usage: {
|
|
16
|
+
inputTokens: usage.input_tokens || 0,
|
|
17
|
+
cacheCreationTokens: usage.cache_creation_input_tokens || 0,
|
|
18
|
+
cacheReadTokens: usage.cache_read_input_tokens || 0,
|
|
19
|
+
outputTokens: usage.output_tokens || 0,
|
|
20
|
+
totalTokens: (usage.input_tokens || 0) +
|
|
21
|
+
(usage.cache_creation_input_tokens || 0) +
|
|
22
|
+
(usage.cache_read_input_tokens || 0) +
|
|
23
|
+
(usage.output_tokens || 0)
|
|
24
|
+
},
|
|
25
|
+
model
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Aggregate multiple token usages into a single total
|
|
30
|
+
*/
|
|
31
|
+
export function aggregateTokenUsage(usages) {
|
|
32
|
+
return usages.reduce((acc, usage) => ({
|
|
33
|
+
inputTokens: acc.inputTokens + usage.inputTokens,
|
|
34
|
+
cacheCreationTokens: acc.cacheCreationTokens + usage.cacheCreationTokens,
|
|
35
|
+
cacheReadTokens: acc.cacheReadTokens + usage.cacheReadTokens,
|
|
36
|
+
outputTokens: acc.outputTokens + usage.outputTokens,
|
|
37
|
+
totalTokens: acc.totalTokens + usage.totalTokens
|
|
38
|
+
}), createEmptyTokenUsage());
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Calculate cost for token usage based on pricing
|
|
42
|
+
*/
|
|
43
|
+
export function calculateCost(usage, pricing) {
|
|
44
|
+
return {
|
|
45
|
+
inputCost: (usage.inputTokens / 1000000) * pricing.input,
|
|
46
|
+
outputCost: (usage.outputTokens / 1000000) * pricing.output,
|
|
47
|
+
cacheCreationCost: (usage.cacheCreationTokens / 1000000) * pricing.cacheCreation,
|
|
48
|
+
cacheReadCost: (usage.cacheReadTokens / 1000000) * pricing.cacheRead,
|
|
49
|
+
totalCost: (usage.inputTokens / 1000000) * pricing.input +
|
|
50
|
+
(usage.outputTokens / 1000000) * pricing.output +
|
|
51
|
+
(usage.cacheCreationTokens / 1000000) * pricing.cacheCreation +
|
|
52
|
+
(usage.cacheReadTokens / 1000000) * pricing.cacheRead
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Create an empty token usage object
|
|
57
|
+
*/
|
|
58
|
+
export function createEmptyTokenUsage() {
|
|
59
|
+
return {
|
|
60
|
+
inputTokens: 0,
|
|
61
|
+
cacheCreationTokens: 0,
|
|
62
|
+
cacheReadTokens: 0,
|
|
63
|
+
outputTokens: 0,
|
|
64
|
+
totalTokens: 0
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createEmptyTokenUsage } from './tokenUsage.js';
|
|
2
|
+
/**
|
|
3
|
+
* Format date to YYYY-MM-DD in local timezone
|
|
4
|
+
*/
|
|
5
|
+
function formatDateLocal(date) {
|
|
6
|
+
const year = date.getFullYear();
|
|
7
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
8
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
9
|
+
return `${year}-${month}-${day}`;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Fill in missing dates in daily statistics with zero values
|
|
13
|
+
* @param dailyMap Map of date string to usage data
|
|
14
|
+
* @param startDate Start date for the range (inclusive)
|
|
15
|
+
* @param endDate End date for the range (inclusive)
|
|
16
|
+
* @returns Array of daily stats with all dates filled in
|
|
17
|
+
*/
|
|
18
|
+
export function fillMissingDates(dailyMap, startDate, endDate) {
|
|
19
|
+
const daily = [];
|
|
20
|
+
const currentDate = new Date(startDate);
|
|
21
|
+
const end = new Date(endDate);
|
|
22
|
+
while (currentDate <= end) {
|
|
23
|
+
const dateKey = formatDateLocal(currentDate);
|
|
24
|
+
const data = dailyMap.get(dateKey);
|
|
25
|
+
daily.push({
|
|
26
|
+
date: dateKey,
|
|
27
|
+
tokenUsage: data?.tokenUsage || createEmptyTokenUsage(),
|
|
28
|
+
sessionCount: data?.sessionIds.size || 0
|
|
29
|
+
});
|
|
30
|
+
currentDate.setDate(currentDate.getDate() + 1);
|
|
31
|
+
}
|
|
32
|
+
return daily;
|
|
33
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { SESSION_WINDOW_DURATION_MS, EXPIRING_SOON_THRESHOLD_MINUTES, PLAN_LIMITS } from '../claude/config.js';
|
|
2
|
+
import { extractTokenUsage } from './tokenStats.js';
|
|
3
|
+
/**
|
|
4
|
+
* Calculate 5-hour session windows from messages
|
|
5
|
+
* Claude Code uses a 5-hour rolling window starting from the first message
|
|
6
|
+
*/
|
|
7
|
+
export function calculateSessionWindows(sessionsData, planConfig) {
|
|
8
|
+
const windows = [];
|
|
9
|
+
const windowMap = new Map();
|
|
10
|
+
// Collect all messages with timestamps
|
|
11
|
+
const allMessages = [];
|
|
12
|
+
for (const session of sessionsData) {
|
|
13
|
+
for (const message of session.messages) {
|
|
14
|
+
if (message.timestamp) {
|
|
15
|
+
allMessages.push({ message, sessionId: session.sessionId });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Sort messages by timestamp
|
|
20
|
+
allMessages.sort((a, b) => new Date(a.message.timestamp).getTime() - new Date(b.message.timestamp).getTime());
|
|
21
|
+
if (allMessages.length === 0) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
// Group messages into 5-hour windows
|
|
25
|
+
for (const { message, sessionId } of allMessages) {
|
|
26
|
+
const messageTime = new Date(message.timestamp);
|
|
27
|
+
let assignedToWindow = false;
|
|
28
|
+
// Check existing windows to see if this message fits
|
|
29
|
+
for (const [windowId, windowData] of windowMap.entries()) {
|
|
30
|
+
const windowEnd = new Date(windowData.startTime.getTime() + SESSION_WINDOW_DURATION_MS);
|
|
31
|
+
if (messageTime >= windowData.startTime && messageTime < windowEnd) {
|
|
32
|
+
windowData.messages.push(message);
|
|
33
|
+
windowData.sessionIds.add(sessionId);
|
|
34
|
+
assignedToWindow = true;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Create new window if message doesn't fit in existing windows
|
|
39
|
+
if (!assignedToWindow) {
|
|
40
|
+
const windowId = `window-${messageTime.getTime()}`;
|
|
41
|
+
windowMap.set(windowId, {
|
|
42
|
+
messages: [message],
|
|
43
|
+
sessionIds: new Set([sessionId]),
|
|
44
|
+
startTime: messageTime
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// Convert window map to SessionWindow objects
|
|
49
|
+
const now = new Date();
|
|
50
|
+
for (const [windowId, windowData] of windowMap.entries()) {
|
|
51
|
+
const startTime = windowData.startTime;
|
|
52
|
+
const endTime = new Date(startTime.getTime() + SESSION_WINDOW_DURATION_MS);
|
|
53
|
+
// Calculate token usage
|
|
54
|
+
let totalTokens = 0;
|
|
55
|
+
for (const message of windowData.messages) {
|
|
56
|
+
const tokenData = extractTokenUsage(message);
|
|
57
|
+
if (tokenData) {
|
|
58
|
+
totalTokens += tokenData.usage.totalTokens;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
// Get first and last message times
|
|
62
|
+
const messageTimes = windowData.messages.map(m => new Date(m.timestamp).getTime());
|
|
63
|
+
const firstMessageTime = new Date(Math.min(...messageTimes));
|
|
64
|
+
const lastMessageTime = new Date(Math.max(...messageTimes));
|
|
65
|
+
// Calculate status
|
|
66
|
+
const remainingMs = endTime.getTime() - now.getTime();
|
|
67
|
+
const remainingMinutes = Math.floor(remainingMs / (1000 * 60));
|
|
68
|
+
const isCurrentWindow = now >= startTime && now < endTime;
|
|
69
|
+
let status = 'expired';
|
|
70
|
+
if (isCurrentWindow) {
|
|
71
|
+
if (remainingMinutes <= EXPIRING_SOON_THRESHOLD_MINUTES) {
|
|
72
|
+
status = 'expiring_soon';
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
status = 'active';
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const tokenLimit = planConfig.limits.tokensPerSession;
|
|
79
|
+
const percentage = (totalTokens / tokenLimit) * 100;
|
|
80
|
+
windows.push({
|
|
81
|
+
id: windowId,
|
|
82
|
+
startTime: startTime.toISOString(),
|
|
83
|
+
endTime: endTime.toISOString(),
|
|
84
|
+
firstMessageTime: firstMessageTime.toISOString(),
|
|
85
|
+
lastMessageTime: lastMessageTime.toISOString(),
|
|
86
|
+
tokenUsage: {
|
|
87
|
+
used: totalTokens,
|
|
88
|
+
limit: tokenLimit,
|
|
89
|
+
percentage: Math.min(percentage, 100)
|
|
90
|
+
},
|
|
91
|
+
messageCount: windowData.messages.length,
|
|
92
|
+
status,
|
|
93
|
+
sessionIds: Array.from(windowData.sessionIds),
|
|
94
|
+
remainingMinutes: isCurrentWindow ? Math.max(remainingMinutes, 0) : undefined,
|
|
95
|
+
isCurrentWindow
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
// Sort windows by start time (most recent first)
|
|
99
|
+
windows.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
|
|
100
|
+
return windows;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Get the currently active session window
|
|
104
|
+
*/
|
|
105
|
+
export function getActiveWindow(windows) {
|
|
106
|
+
return windows.find(w => w.isCurrentWindow) || null;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Detect user's plan based on historical usage (P90 method)
|
|
110
|
+
* Analyzes the 90th percentile of token usage across sessions
|
|
111
|
+
*/
|
|
112
|
+
export function detectUserPlan(windows) {
|
|
113
|
+
if (windows.length === 0) {
|
|
114
|
+
return {
|
|
115
|
+
name: 'Custom',
|
|
116
|
+
limits: PLAN_LIMITS.Custom,
|
|
117
|
+
autoDetected: false
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// Get token usage from all windows
|
|
121
|
+
const tokenUsages = windows.map(w => w.tokenUsage.used).sort((a, b) => a - b);
|
|
122
|
+
// Calculate P90 (90th percentile)
|
|
123
|
+
const p90Index = Math.floor(tokenUsages.length * 0.9);
|
|
124
|
+
const p90Value = tokenUsages[p90Index] || 0;
|
|
125
|
+
// Match to known plans with tolerance
|
|
126
|
+
const tolerance = 0.15; // 15% tolerance
|
|
127
|
+
for (const [planName, limits] of Object.entries(PLAN_LIMITS)) {
|
|
128
|
+
const planLimit = limits.tokensPerSession;
|
|
129
|
+
const lowerBound = planLimit * (1 - tolerance);
|
|
130
|
+
const upperBound = planLimit * (1 + tolerance);
|
|
131
|
+
if (p90Value >= lowerBound && p90Value <= upperBound) {
|
|
132
|
+
const confidence = 100 - Math.abs((p90Value - planLimit) / planLimit) * 100;
|
|
133
|
+
return {
|
|
134
|
+
name: planName,
|
|
135
|
+
limits,
|
|
136
|
+
autoDetected: true,
|
|
137
|
+
confidence: Math.round(confidence)
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
// If no match, use Custom plan with P90 as limit
|
|
142
|
+
return {
|
|
143
|
+
name: 'Custom',
|
|
144
|
+
limits: {
|
|
145
|
+
tokensPerSession: Math.round(p90Value * 1.1), // Add 10% buffer
|
|
146
|
+
messagesPerSession: undefined
|
|
147
|
+
},
|
|
148
|
+
autoDetected: true,
|
|
149
|
+
confidence: 50
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Load plan configuration from settings or auto-detect
|
|
154
|
+
*/
|
|
155
|
+
export function loadPlanConfig(windows) {
|
|
156
|
+
// TODO: Load from user settings/config file if exists
|
|
157
|
+
// For now, use auto-detection
|
|
158
|
+
return detectUserPlan(windows);
|
|
159
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-session-viewer",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"claude-session-viewer": "./bin/cli.js"
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
],
|
|
15
15
|
"scripts": {
|
|
16
16
|
"dev": "node bin/dev.js",
|
|
17
|
-
"dev:server": "tsx watch src/server/index.ts",
|
|
17
|
+
"dev:server": "tsx watch --clear-screen=false src/server/index.ts",
|
|
18
18
|
"dev:client": "vite",
|
|
19
19
|
"build:server": "tsc -p tsconfig.server.json",
|
|
20
20
|
"build:client": "vite build --outDir dist/client",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}.container{width:100%}@media (min-width: 640px){.container{max-width:640px}}@media (min-width: 768px){.container{max-width:768px}}@media (min-width: 1024px){.container{max-width:1024px}}@media (min-width: 1280px){.container{max-width:1280px}}@media (min-width: 1536px){.container{max-width:1536px}}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.sticky{position:sticky}.right-0{right:0}.top-0{top:0}.z-10{z-index:10}.mx-auto{margin-left:auto;margin-right:auto}.mb-1{margin-bottom:.25rem}.mb-2{margin-bottom:.5rem}.mb-3{margin-bottom:.75rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.ml-2{margin-left:.5rem}.ml-4{margin-left:1rem}.ml-6{margin-left:1.5rem}.ml-auto{margin-left:auto}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-3{margin-top:.75rem}.mt-4{margin-top:1rem}.mt-6{margin-top:1.5rem}.mt-8{margin-top:2rem}.line-clamp-2{overflow:hidden;display:-webkit-box;-webkit-box-orient:vertical;-webkit-line-clamp:2}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.h-12{height:3rem}.h-2{height:.5rem}.h-3{height:.75rem}.h-4{height:1rem}.h-5{height:1.25rem}.h-6{height:1.5rem}.h-64{height:16rem}.h-8{height:2rem}.h-96{height:24rem}.h-full{height:100%}.h-screen{height:100vh}.w-1{width:.25rem}.w-1\/2{width:50%}.w-1\/3{width:33.333333%}.w-12{width:3rem}.w-16{width:4rem}.w-24{width:6rem}.w-3{width:.75rem}.w-3\/4{width:75%}.w-32{width:8rem}.w-4{width:1rem}.w-4\/5{width:80%}.w-5\/6{width:83.333333%}.w-64{width:16rem}.w-8{width:2rem}.w-full{width:100%}.min-w-0{min-width:0px}.max-w-4xl{max-width:56rem}.max-w-7xl{max-width:80rem}.flex-1{flex:1 1 0%}.flex-shrink-0{flex-shrink:0}.rotate-90{--tw-rotate: 90deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}@keyframes pulse{50%{opacity:.5}}.animate-pulse{animation:pulse 2s cubic-bezier(.4,0,.6,1) infinite}.cursor-col-resize{cursor:col-resize}.resize{resize:both}.auto-rows-fr{grid-auto-rows:minmax(0,1fr)}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-start{align-items:flex-start}.items-center{align-items:center}.items-baseline{align-items:baseline}.justify-center{justify-content:center}.justify-between{justify-content:space-between}.gap-1{gap:.25rem}.gap-2{gap:.5rem}.gap-3{gap:.75rem}.gap-4{gap:1rem}.gap-6{gap:1.5rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-y-3>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(.75rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.75rem * var(--tw-space-y-reverse))}.space-y-4>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1rem * var(--tw-space-y-reverse))}.space-y-6>:not([hidden])~:not([hidden]){--tw-space-y-reverse: 0;margin-top:calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(1.5rem * var(--tw-space-y-reverse))}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-y-auto{overflow-y:auto}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.whitespace-pre-wrap{white-space:pre-wrap}.rounded{border-radius:.25rem}.rounded-full{border-radius:9999px}.rounded-lg{border-radius:.5rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-l{border-left-width:1px}.border-l-2{border-left-width:2px}.border-r{border-right-width:1px}.border-t{border-top-width:1px}.border-blue-400{--tw-border-opacity: 1;border-color:rgb(96 165 250 / var(--tw-border-opacity, 1))}.border-blue-500{--tw-border-opacity: 1;border-color:rgb(59 130 246 / var(--tw-border-opacity, 1))}.border-blue-500\/20{border-color:#3b82f633}.border-cyan-500\/20{border-color:#06b6d433}.border-gray-700{--tw-border-opacity: 1;border-color:rgb(55 65 81 / var(--tw-border-opacity, 1))}.border-green-500\/20{border-color:#22c55e33}.border-orange-500\/20{border-color:#f9731633}.border-pink-500\/20{border-color:#ec489933}.border-purple-500\/20{border-color:#a855f733}.border-transparent{border-color:transparent}.bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}.bg-blue-500\/10{background-color:#3b82f61a}.bg-blue-600{--tw-bg-opacity: 1;background-color:rgb(37 99 235 / var(--tw-bg-opacity, 1))}.bg-blue-900{--tw-bg-opacity: 1;background-color:rgb(30 58 138 / var(--tw-bg-opacity, 1))}.bg-blue-900\/50{background-color:#1e3a8a80}.bg-cyan-500{--tw-bg-opacity: 1;background-color:rgb(6 182 212 / var(--tw-bg-opacity, 1))}.bg-cyan-500\/10{background-color:#06b6d41a}.bg-gray-700{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.bg-gray-800{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.bg-gray-800\/30{background-color:#1f29374d}.bg-gray-800\/50{background-color:#1f293780}.bg-gray-900{--tw-bg-opacity: 1;background-color:rgb(17 24 39 / var(--tw-bg-opacity, 1))}.bg-gray-900\/50{background-color:#11182780}.bg-gray-900\/70{background-color:#111827b3}.bg-green-500{--tw-bg-opacity: 1;background-color:rgb(34 197 94 / var(--tw-bg-opacity, 1))}.bg-green-500\/10{background-color:#22c55e1a}.bg-green-900{--tw-bg-opacity: 1;background-color:rgb(20 83 45 / var(--tw-bg-opacity, 1))}.bg-green-900\/30{background-color:#14532d4d}.bg-green-900\/50{background-color:#14532d80}.bg-orange-500{--tw-bg-opacity: 1;background-color:rgb(249 115 22 / var(--tw-bg-opacity, 1))}.bg-orange-500\/10{background-color:#f973161a}.bg-pink-500{--tw-bg-opacity: 1;background-color:rgb(236 72 153 / var(--tw-bg-opacity, 1))}.bg-pink-500\/10{background-color:#ec48991a}.bg-purple-500{--tw-bg-opacity: 1;background-color:rgb(168 85 247 / var(--tw-bg-opacity, 1))}.bg-purple-500\/10{background-color:#a855f71a}.bg-purple-700{--tw-bg-opacity: 1;background-color:rgb(126 34 206 / var(--tw-bg-opacity, 1))}.bg-purple-900\/50{background-color:#581c8780}.bg-red-500{--tw-bg-opacity: 1;background-color:rgb(239 68 68 / var(--tw-bg-opacity, 1))}.bg-transparent{background-color:transparent}.bg-yellow-500{--tw-bg-opacity: 1;background-color:rgb(234 179 8 / var(--tw-bg-opacity, 1))}.bg-yellow-900\/30{background-color:#713f124d}.bg-gradient-to-r{background-image:linear-gradient(to right,var(--tw-gradient-stops))}.from-blue-500{--tw-gradient-from: #3b82f6 var(--tw-gradient-from-position);--tw-gradient-to: rgb(59 130 246 / 0) var(--tw-gradient-to-position);--tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to)}.to-green-500{--tw-gradient-to: #22c55e var(--tw-gradient-to-position)}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.px-1\.5{padding-left:.375rem;padding-right:.375rem}.px-2{padding-left:.5rem;padding-right:.5rem}.px-3{padding-left:.75rem;padding-right:.75rem}.px-4{padding-left:1rem;padding-right:1rem}.py-0\.5{padding-top:.125rem;padding-bottom:.125rem}.py-1{padding-top:.25rem;padding-bottom:.25rem}.py-2{padding-top:.5rem;padding-bottom:.5rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.pb-4{padding-bottom:1rem}.pl-14{padding-left:3.5rem}.pl-4{padding-left:1rem}.pl-8{padding-left:2rem}.pt-6{padding-top:1.5rem}.text-left{text-align:left}.text-center{text-align:center}.text-right{text-align:right}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.text-\[10px\]{font-size:10px}.text-lg{font-size:1.125rem;line-height:1.75rem}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-xs{font-size:.75rem;line-height:1rem}.font-bold{font-weight:700}.font-medium{font-weight:500}.font-semibold{font-weight:600}.leading-tight{line-height:1.25}.text-blue-200{--tw-text-opacity: 1;color:rgb(191 219 254 / var(--tw-text-opacity, 1))}.text-blue-300{--tw-text-opacity: 1;color:rgb(147 197 253 / var(--tw-text-opacity, 1))}.text-blue-400{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.text-cyan-400{--tw-text-opacity: 1;color:rgb(34 211 238 / var(--tw-text-opacity, 1))}.text-gray-200{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.text-gray-300{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.text-gray-400{--tw-text-opacity: 1;color:rgb(156 163 175 / var(--tw-text-opacity, 1))}.text-gray-500{--tw-text-opacity: 1;color:rgb(107 114 128 / var(--tw-text-opacity, 1))}.text-gray-600{--tw-text-opacity: 1;color:rgb(75 85 99 / var(--tw-text-opacity, 1))}.text-green-200{--tw-text-opacity: 1;color:rgb(187 247 208 / var(--tw-text-opacity, 1))}.text-green-300{--tw-text-opacity: 1;color:rgb(134 239 172 / var(--tw-text-opacity, 1))}.text-green-400{--tw-text-opacity: 1;color:rgb(74 222 128 / var(--tw-text-opacity, 1))}.text-purple-100{--tw-text-opacity: 1;color:rgb(243 232 255 / var(--tw-text-opacity, 1))}.text-purple-300{--tw-text-opacity: 1;color:rgb(216 180 254 / var(--tw-text-opacity, 1))}.text-purple-400{--tw-text-opacity: 1;color:rgb(192 132 252 / var(--tw-text-opacity, 1))}.text-red-400{--tw-text-opacity: 1;color:rgb(248 113 113 / var(--tw-text-opacity, 1))}.text-white{--tw-text-opacity: 1;color:rgb(255 255 255 / var(--tw-text-opacity, 1))}.text-yellow-400{--tw-text-opacity: 1;color:rgb(250 204 21 / var(--tw-text-opacity, 1))}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.transition-all{transition-property:all;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-colors{transition-property:color,background-color,border-color,text-decoration-color,fill,stroke;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-opacity{transition-property:opacity;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.transition-transform{transition-property:transform;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-duration:.15s}.duration-200{transition-duration:.2s}.duration-300{transition-duration:.3s}.\@container{container-type:inline-size}:root{font-family:Inter,system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}body{margin:0;display:flex;place-items:center;min-width:320px;min-height:100vh}#root{width:100%;min-height:100vh}*{scrollbar-width:thin;scrollbar-color:#4B5563 #1F2937}*::-webkit-scrollbar{width:8px;height:8px}*::-webkit-scrollbar-track{background:#1f2937}*::-webkit-scrollbar-thumb{background-color:#4b5563;border-radius:4px}*::-webkit-scrollbar-thumb:hover{background-color:#6b7280}.last\:border-b-0:last-child{border-bottom-width:0px}.last\:pb-0:last-child{padding-bottom:0}.hover\:bg-gray-700:hover{--tw-bg-opacity: 1;background-color:rgb(55 65 81 / var(--tw-bg-opacity, 1))}.hover\:bg-gray-700\/50:hover{background-color:#37415180}.hover\:bg-gray-800:hover{--tw-bg-opacity: 1;background-color:rgb(31 41 55 / var(--tw-bg-opacity, 1))}.hover\:bg-purple-600:hover{--tw-bg-opacity: 1;background-color:rgb(147 51 234 / var(--tw-bg-opacity, 1))}.hover\:text-blue-400:hover{--tw-text-opacity: 1;color:rgb(96 165 250 / var(--tw-text-opacity, 1))}.hover\:text-gray-200:hover{--tw-text-opacity: 1;color:rgb(229 231 235 / var(--tw-text-opacity, 1))}.hover\:text-gray-300:hover{--tw-text-opacity: 1;color:rgb(209 213 219 / var(--tw-text-opacity, 1))}.group:hover .group-hover\:bg-blue-500{--tw-bg-opacity: 1;background-color:rgb(59 130 246 / var(--tw-bg-opacity, 1))}@container (min-width: 300px){.\@\[300px\]\:inline{display:inline}.\@\[300px\]\:hidden{display:none}}@media (min-width: 768px){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media (min-width: 1024px){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media (min-width: 1280px){.xl\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.xl\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}
|