copilot-usage-tui 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/LICENSE +21 -0
- package/README.md +83 -0
- package/package.json +27 -0
- package/src/api/auth.ts +148 -0
- package/src/api/github.ts +92 -0
- package/src/config/config.ts +70 -0
- package/src/index.ts +13 -0
- package/src/types.ts +90 -0
- package/src/ui/app.ts +215 -0
- package/src/ui/components/chart.ts +141 -0
- package/src/ui/components/progressBar.ts +114 -0
- package/src/ui/components/table.ts +136 -0
- package/src/ui/screens/auth.ts +107 -0
- package/src/ui/screens/dashboard.ts +275 -0
- package/src/ui/screens/setup.ts +188 -0
- package/src/utils/format.ts +61 -0
- package/src/utils/prediction.ts +62 -0
- package/tsconfig.json +19 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
type CliRenderer,
|
|
5
|
+
} from "@opentui/core"
|
|
6
|
+
import { THEME, type UsageSummary, type Config } from "../../types.ts"
|
|
7
|
+
import { formatCurrency, formatNumber, getMonthName } from "../../utils/format.ts"
|
|
8
|
+
import {
|
|
9
|
+
predictMonthlyUsage,
|
|
10
|
+
predictOverageCost,
|
|
11
|
+
getDaysInMonth,
|
|
12
|
+
getCurrentDay,
|
|
13
|
+
} from "../../utils/prediction.ts"
|
|
14
|
+
import { ProgressBarComponent } from "../components/progressBar.ts"
|
|
15
|
+
import { ChartComponent } from "../components/chart.ts"
|
|
16
|
+
|
|
17
|
+
export interface DashboardScreenOptions {
|
|
18
|
+
usage: UsageSummary
|
|
19
|
+
config: Config
|
|
20
|
+
onRefresh: () => void
|
|
21
|
+
onSettings: () => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class DashboardScreen {
|
|
25
|
+
private renderer: CliRenderer
|
|
26
|
+
private container: BoxRenderable
|
|
27
|
+
private progressBar: ProgressBarComponent | null = null
|
|
28
|
+
private chart: ChartComponent | null = null
|
|
29
|
+
|
|
30
|
+
constructor(renderer: CliRenderer, options: DashboardScreenOptions) {
|
|
31
|
+
this.renderer = renderer
|
|
32
|
+
const { usage, config } = options
|
|
33
|
+
|
|
34
|
+
// Main container
|
|
35
|
+
this.container = new BoxRenderable(renderer, {
|
|
36
|
+
id: "dashboard-screen",
|
|
37
|
+
width: "100%",
|
|
38
|
+
height: "100%",
|
|
39
|
+
flexDirection: "column",
|
|
40
|
+
backgroundColor: THEME.bg,
|
|
41
|
+
padding: 1,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
this.createUI(usage, config)
|
|
45
|
+
renderer.root.add(this.container)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private createUI(usage: UsageSummary, config: Config) {
|
|
49
|
+
const { quota } = config
|
|
50
|
+
const monthName = getMonthName(usage.month)
|
|
51
|
+
|
|
52
|
+
// Header
|
|
53
|
+
const headerBox = new BoxRenderable(this.renderer, {
|
|
54
|
+
id: "dashboard-header",
|
|
55
|
+
width: "100%",
|
|
56
|
+
flexDirection: "column",
|
|
57
|
+
alignItems: "center",
|
|
58
|
+
padding: 1,
|
|
59
|
+
border: ["top", "left", "right", "bottom"],
|
|
60
|
+
borderStyle: "rounded",
|
|
61
|
+
borderColor: THEME.blue,
|
|
62
|
+
backgroundColor: THEME.bgDark,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const title = new TextRenderable(this.renderer, {
|
|
66
|
+
id: "dashboard-title",
|
|
67
|
+
content: `GitHub Copilot Usage - ${monthName} ${usage.year}`,
|
|
68
|
+
fg: THEME.blue,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const userText = new TextRenderable(this.renderer, {
|
|
72
|
+
id: "dashboard-user",
|
|
73
|
+
content: `User: ${usage.user}`,
|
|
74
|
+
fg: THEME.fgMuted,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
headerBox.add(title)
|
|
78
|
+
headerBox.add(userText)
|
|
79
|
+
this.container.add(headerBox)
|
|
80
|
+
|
|
81
|
+
// Spacer
|
|
82
|
+
this.container.add(new BoxRenderable(this.renderer, { id: "spacer-1", height: 1 }))
|
|
83
|
+
|
|
84
|
+
// Progress section
|
|
85
|
+
const progressSection = new BoxRenderable(this.renderer, {
|
|
86
|
+
id: "progress-section",
|
|
87
|
+
width: "100%",
|
|
88
|
+
flexDirection: "column",
|
|
89
|
+
padding: 1,
|
|
90
|
+
border: ["top", "left", "right", "bottom"],
|
|
91
|
+
borderStyle: "rounded",
|
|
92
|
+
borderColor: THEME.border,
|
|
93
|
+
backgroundColor: THEME.bgDark,
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
this.progressBar = new ProgressBarComponent(this.renderer, {
|
|
97
|
+
used: usage.totalRequests,
|
|
98
|
+
quota: quota,
|
|
99
|
+
width: 50,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
progressSection.add(this.progressBar.getContainer())
|
|
103
|
+
this.container.add(progressSection)
|
|
104
|
+
|
|
105
|
+
// Spacer
|
|
106
|
+
this.container.add(new BoxRenderable(this.renderer, { id: "spacer-2", height: 1 }))
|
|
107
|
+
|
|
108
|
+
// Chart section
|
|
109
|
+
const chartSection = new BoxRenderable(this.renderer, {
|
|
110
|
+
id: "chart-section",
|
|
111
|
+
width: "100%",
|
|
112
|
+
flexDirection: "column",
|
|
113
|
+
padding: 1,
|
|
114
|
+
border: ["top", "left", "right", "bottom"],
|
|
115
|
+
borderStyle: "rounded",
|
|
116
|
+
borderColor: THEME.border,
|
|
117
|
+
backgroundColor: THEME.bgDark,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
this.chart = new ChartComponent(this.renderer, {
|
|
121
|
+
items: usage.items,
|
|
122
|
+
maxRows: 6,
|
|
123
|
+
barWidth: 35,
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
chartSection.add(this.chart.getContainer())
|
|
127
|
+
this.container.add(chartSection)
|
|
128
|
+
|
|
129
|
+
// Spacer
|
|
130
|
+
this.container.add(new BoxRenderable(this.renderer, { id: "spacer-3", height: 1 }))
|
|
131
|
+
|
|
132
|
+
// Summary and prediction section (side by side)
|
|
133
|
+
const bottomSection = new BoxRenderable(this.renderer, {
|
|
134
|
+
id: "bottom-section",
|
|
135
|
+
width: "100%",
|
|
136
|
+
flexDirection: "row",
|
|
137
|
+
gap: 2,
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
// Costs summary
|
|
141
|
+
const costsBox = new BoxRenderable(this.renderer, {
|
|
142
|
+
id: "costs-box",
|
|
143
|
+
flexGrow: 1,
|
|
144
|
+
flexDirection: "column",
|
|
145
|
+
padding: 1,
|
|
146
|
+
border: ["top", "left", "right", "bottom"],
|
|
147
|
+
borderStyle: "rounded",
|
|
148
|
+
borderColor: THEME.border,
|
|
149
|
+
backgroundColor: THEME.bgDark,
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const costsTitle = new TextRenderable(this.renderer, {
|
|
153
|
+
id: "costs-title",
|
|
154
|
+
content: "Costs",
|
|
155
|
+
fg: THEME.fgMuted,
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
const costsSeparator = new TextRenderable(this.renderer, {
|
|
159
|
+
id: "costs-separator",
|
|
160
|
+
content: "─".repeat(25),
|
|
161
|
+
fg: THEME.border,
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
const grossLine = new TextRenderable(this.renderer, {
|
|
165
|
+
id: "costs-gross",
|
|
166
|
+
content: `Gross Amount: ${formatCurrency(usage.grossAmount)}`,
|
|
167
|
+
fg: THEME.fg,
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const discountLine = new TextRenderable(this.renderer, {
|
|
171
|
+
id: "costs-discount",
|
|
172
|
+
content: `Discount: -${formatCurrency(usage.discountAmount)}`,
|
|
173
|
+
fg: THEME.green,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const netLine = new TextRenderable(this.renderer, {
|
|
177
|
+
id: "costs-net",
|
|
178
|
+
content: `Net Amount: ${formatCurrency(usage.netAmount)}`,
|
|
179
|
+
fg: usage.netAmount > 0 ? THEME.yellow : THEME.green,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
costsBox.add(costsTitle)
|
|
183
|
+
costsBox.add(costsSeparator)
|
|
184
|
+
costsBox.add(grossLine)
|
|
185
|
+
costsBox.add(discountLine)
|
|
186
|
+
costsBox.add(netLine)
|
|
187
|
+
|
|
188
|
+
// Prediction box
|
|
189
|
+
const predictionBox = new BoxRenderable(this.renderer, {
|
|
190
|
+
id: "prediction-box",
|
|
191
|
+
flexGrow: 1,
|
|
192
|
+
flexDirection: "column",
|
|
193
|
+
padding: 1,
|
|
194
|
+
border: ["top", "left", "right", "bottom"],
|
|
195
|
+
borderStyle: "rounded",
|
|
196
|
+
borderColor: THEME.border,
|
|
197
|
+
backgroundColor: THEME.bgDark,
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
const predictionTitle = new TextRenderable(this.renderer, {
|
|
201
|
+
id: "prediction-title",
|
|
202
|
+
content: "Prediction",
|
|
203
|
+
fg: THEME.fgMuted,
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
const predictionSeparator = new TextRenderable(this.renderer, {
|
|
207
|
+
id: "prediction-separator",
|
|
208
|
+
content: "─".repeat(25),
|
|
209
|
+
fg: THEME.border,
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
// Calculate prediction
|
|
213
|
+
const currentDay = getCurrentDay()
|
|
214
|
+
const daysInMonth = getDaysInMonth(usage.year, usage.month)
|
|
215
|
+
const predictedUsage = predictMonthlyUsage(usage.totalRequests, currentDay, daysInMonth)
|
|
216
|
+
const predictedOverage = predictOverageCost(predictedUsage, quota)
|
|
217
|
+
|
|
218
|
+
const predictionLine = new TextRenderable(this.renderer, {
|
|
219
|
+
id: "prediction-usage",
|
|
220
|
+
content: `End of month: ~${formatNumber(predictedUsage)} reqs`,
|
|
221
|
+
fg: predictedUsage > quota ? THEME.yellow : THEME.fg,
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
const overageLine = new TextRenderable(this.renderer, {
|
|
225
|
+
id: "prediction-overage",
|
|
226
|
+
content: `Overage cost: ${formatCurrency(predictedOverage)}`,
|
|
227
|
+
fg: predictedOverage > 0 ? THEME.red : THEME.green,
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
const daysLine = new TextRenderable(this.renderer, {
|
|
231
|
+
id: "prediction-days",
|
|
232
|
+
content: `Day ${currentDay} of ${daysInMonth}`,
|
|
233
|
+
fg: THEME.fgMuted,
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
predictionBox.add(predictionTitle)
|
|
237
|
+
predictionBox.add(predictionSeparator)
|
|
238
|
+
predictionBox.add(predictionLine)
|
|
239
|
+
predictionBox.add(overageLine)
|
|
240
|
+
predictionBox.add(daysLine)
|
|
241
|
+
|
|
242
|
+
bottomSection.add(costsBox)
|
|
243
|
+
bottomSection.add(predictionBox)
|
|
244
|
+
this.container.add(bottomSection)
|
|
245
|
+
|
|
246
|
+
// Spacer
|
|
247
|
+
this.container.add(new BoxRenderable(this.renderer, { id: "spacer-4", height: 1 }))
|
|
248
|
+
|
|
249
|
+
// Footer with keybindings
|
|
250
|
+
const footer = new BoxRenderable(this.renderer, {
|
|
251
|
+
id: "dashboard-footer",
|
|
252
|
+
width: "100%",
|
|
253
|
+
flexDirection: "row",
|
|
254
|
+
justifyContent: "center",
|
|
255
|
+
padding: 1,
|
|
256
|
+
border: ["top", "left", "right", "bottom"],
|
|
257
|
+
borderStyle: "rounded",
|
|
258
|
+
borderColor: THEME.border,
|
|
259
|
+
backgroundColor: THEME.bgDark,
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
const keybindings = new TextRenderable(this.renderer, {
|
|
263
|
+
id: "footer-keybindings",
|
|
264
|
+
content: "[r] Refresh [s] Settings [q] Quit",
|
|
265
|
+
fg: THEME.fgMuted,
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
footer.add(keybindings)
|
|
269
|
+
this.container.add(footer)
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
public destroy() {
|
|
273
|
+
this.container.destroyRecursively()
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BoxRenderable,
|
|
3
|
+
TextRenderable,
|
|
4
|
+
SelectRenderable,
|
|
5
|
+
InputRenderable,
|
|
6
|
+
type CliRenderer,
|
|
7
|
+
type KeyEvent,
|
|
8
|
+
SelectRenderableEvents,
|
|
9
|
+
InputRenderableEvents,
|
|
10
|
+
} from "@opentui/core"
|
|
11
|
+
import { THEME, PLAN_OPTIONS, type Config } from "../../types.ts"
|
|
12
|
+
import { saveConfig } from "../../config/config.ts"
|
|
13
|
+
import { formatNumber } from "../../utils/format.ts"
|
|
14
|
+
|
|
15
|
+
export interface SetupScreenOptions {
|
|
16
|
+
onComplete: (config: Config) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export class SetupScreen {
|
|
20
|
+
private renderer: CliRenderer
|
|
21
|
+
private container: BoxRenderable
|
|
22
|
+
private select: SelectRenderable | null = null
|
|
23
|
+
private customInput: InputRenderable | null = null
|
|
24
|
+
private onComplete: (config: Config) => void
|
|
25
|
+
private showingCustomInput = false
|
|
26
|
+
|
|
27
|
+
constructor(renderer: CliRenderer, options: SetupScreenOptions) {
|
|
28
|
+
this.renderer = renderer
|
|
29
|
+
this.onComplete = options.onComplete
|
|
30
|
+
|
|
31
|
+
// Main container
|
|
32
|
+
this.container = new BoxRenderable(renderer, {
|
|
33
|
+
id: "setup-screen",
|
|
34
|
+
width: "100%",
|
|
35
|
+
height: "100%",
|
|
36
|
+
flexDirection: "column",
|
|
37
|
+
justifyContent: "center",
|
|
38
|
+
alignItems: "center",
|
|
39
|
+
backgroundColor: THEME.bg,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
this.createUI()
|
|
43
|
+
renderer.root.add(this.container)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private createUI() {
|
|
47
|
+
// Title box
|
|
48
|
+
const titleBox = new BoxRenderable(this.renderer, {
|
|
49
|
+
id: "setup-title-box",
|
|
50
|
+
width: 60,
|
|
51
|
+
flexDirection: "column",
|
|
52
|
+
alignItems: "center",
|
|
53
|
+
padding: 1,
|
|
54
|
+
border: ["top", "left", "right"],
|
|
55
|
+
borderStyle: "rounded",
|
|
56
|
+
borderColor: THEME.blue,
|
|
57
|
+
backgroundColor: THEME.bgDark,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const title = new TextRenderable(this.renderer, {
|
|
61
|
+
id: "setup-title",
|
|
62
|
+
content: "Copilot Usage Setup",
|
|
63
|
+
fg: THEME.blue,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
titleBox.add(title)
|
|
67
|
+
|
|
68
|
+
// Content box
|
|
69
|
+
const contentBox = new BoxRenderable(this.renderer, {
|
|
70
|
+
id: "setup-content-box",
|
|
71
|
+
width: 60,
|
|
72
|
+
flexDirection: "column",
|
|
73
|
+
padding: 2,
|
|
74
|
+
border: ["bottom", "left", "right"],
|
|
75
|
+
borderStyle: "rounded",
|
|
76
|
+
borderColor: THEME.border,
|
|
77
|
+
backgroundColor: THEME.bgDark,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const description = new TextRenderable(this.renderer, {
|
|
81
|
+
id: "setup-description",
|
|
82
|
+
content: "Select your Copilot plan to set your monthly\npremium request quota:",
|
|
83
|
+
fg: THEME.fg,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
const spacer = new BoxRenderable(this.renderer, {
|
|
87
|
+
id: "setup-spacer",
|
|
88
|
+
height: 1,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
// Plan options for select
|
|
92
|
+
const selectOptions = PLAN_OPTIONS.map((plan) => ({
|
|
93
|
+
name: plan.name,
|
|
94
|
+
label: `${plan.label.padEnd(12)} ${plan.description}`,
|
|
95
|
+
}))
|
|
96
|
+
|
|
97
|
+
this.select = new SelectRenderable(this.renderer, {
|
|
98
|
+
id: "setup-select",
|
|
99
|
+
options: selectOptions,
|
|
100
|
+
width: 50,
|
|
101
|
+
height: PLAN_OPTIONS.length + 2,
|
|
102
|
+
selectedColor: THEME.blue,
|
|
103
|
+
unselectedColor: THEME.fgMuted,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
// Custom input (hidden initially)
|
|
107
|
+
this.customInput = new InputRenderable(this.renderer, {
|
|
108
|
+
id: "setup-custom-input",
|
|
109
|
+
placeholder: "Enter custom quota (e.g., 500)",
|
|
110
|
+
width: 40,
|
|
111
|
+
fg: THEME.fg,
|
|
112
|
+
visible: false,
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
const hint = new TextRenderable(this.renderer, {
|
|
116
|
+
id: "setup-hint",
|
|
117
|
+
content: "Use ↑/↓ to navigate, Enter to select",
|
|
118
|
+
fg: THEME.fgMuted,
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
contentBox.add(description)
|
|
122
|
+
contentBox.add(spacer)
|
|
123
|
+
contentBox.add(this.select)
|
|
124
|
+
contentBox.add(this.customInput)
|
|
125
|
+
contentBox.add(new BoxRenderable(this.renderer, { id: "hint-spacer", height: 1 }))
|
|
126
|
+
contentBox.add(hint)
|
|
127
|
+
|
|
128
|
+
this.container.add(titleBox)
|
|
129
|
+
this.container.add(contentBox)
|
|
130
|
+
|
|
131
|
+
// Handle selection
|
|
132
|
+
this.select.on(SelectRenderableEvents.ITEM_SELECTED, (_index, option) => {
|
|
133
|
+
const plan = PLAN_OPTIONS.find((p) => p.name === option.name)
|
|
134
|
+
if (!plan) return
|
|
135
|
+
|
|
136
|
+
if (plan.name === "custom") {
|
|
137
|
+
this.showCustomInput()
|
|
138
|
+
} else {
|
|
139
|
+
this.savePlan(plan.name, plan.quota)
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
// Handle custom input
|
|
144
|
+
this.customInput.on(InputRenderableEvents.CHANGE, (value) => {
|
|
145
|
+
const quota = parseInt(value, 10)
|
|
146
|
+
if (!isNaN(quota) && quota > 0) {
|
|
147
|
+
this.savePlan("custom", quota)
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
this.select.focus()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private showCustomInput() {
|
|
155
|
+
if (this.select && this.customInput) {
|
|
156
|
+
this.showingCustomInput = true
|
|
157
|
+
this.select.visible = false
|
|
158
|
+
this.customInput.visible = true
|
|
159
|
+
this.customInput.focus()
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async savePlan(planName: string, quota: number) {
|
|
164
|
+
const config: Config = { plan: planName, quota }
|
|
165
|
+
await saveConfig(config)
|
|
166
|
+
this.onComplete(config)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
public handleKey(event: KeyEvent) {
|
|
170
|
+
if (this.showingCustomInput && event.name === "escape") {
|
|
171
|
+
// Go back to plan selection
|
|
172
|
+
this.showingCustomInput = false
|
|
173
|
+
if (this.customInput) {
|
|
174
|
+
this.customInput.visible = false
|
|
175
|
+
this.customInput.content = ""
|
|
176
|
+
}
|
|
177
|
+
if (this.select) {
|
|
178
|
+
this.select.visible = true
|
|
179
|
+
this.select.focus()
|
|
180
|
+
}
|
|
181
|
+
event.preventDefault()
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
public destroy() {
|
|
186
|
+
this.container.destroyRecursively()
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a number as currency (USD)
|
|
3
|
+
*/
|
|
4
|
+
export function formatCurrency(amount: number): string {
|
|
5
|
+
return `$${amount.toFixed(2)}`
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Format a number with commas as thousand separators
|
|
10
|
+
*/
|
|
11
|
+
export function formatNumber(value: number): string {
|
|
12
|
+
return Math.round(value).toLocaleString("en-US")
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Format a percentage value
|
|
17
|
+
*/
|
|
18
|
+
export function formatPercent(value: number): string {
|
|
19
|
+
return `${value.toFixed(1)}%`
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Calculate percentage with bounds checking
|
|
24
|
+
*/
|
|
25
|
+
export function calculatePercent(used: number, total: number): number {
|
|
26
|
+
if (total <= 0) return 0
|
|
27
|
+
return Math.min(100, (used / total) * 100)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Get month name from month number (1-12)
|
|
32
|
+
*/
|
|
33
|
+
export function getMonthName(month: number): string {
|
|
34
|
+
const months = [
|
|
35
|
+
"January", "February", "March", "April", "May", "June",
|
|
36
|
+
"July", "August", "September", "October", "November", "December"
|
|
37
|
+
]
|
|
38
|
+
return months[month - 1] || "Unknown"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Pad a string to a fixed width (right-aligned for numbers)
|
|
43
|
+
*/
|
|
44
|
+
export function padLeft(str: string, width: number): string {
|
|
45
|
+
return str.padStart(width)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Pad a string to a fixed width (left-aligned for text)
|
|
50
|
+
*/
|
|
51
|
+
export function padRight(str: string, width: number): string {
|
|
52
|
+
return str.padEnd(width)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Truncate a string to a maximum length with ellipsis
|
|
57
|
+
*/
|
|
58
|
+
export function truncate(str: string, maxLength: number): string {
|
|
59
|
+
if (str.length <= maxLength) return str
|
|
60
|
+
return str.slice(0, maxLength - 1) + "…"
|
|
61
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Get the number of days in a given month
|
|
3
|
+
*/
|
|
4
|
+
export function getDaysInMonth(year: number, month: number): number {
|
|
5
|
+
return new Date(year, month, 0).getDate()
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Get the current day of the month (1-31)
|
|
10
|
+
*/
|
|
11
|
+
export function getCurrentDay(): number {
|
|
12
|
+
return new Date().getDate()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get current year and month
|
|
17
|
+
*/
|
|
18
|
+
export function getCurrentYearMonth(): { year: number; month: number } {
|
|
19
|
+
const now = new Date()
|
|
20
|
+
return {
|
|
21
|
+
year: now.getFullYear(),
|
|
22
|
+
month: now.getMonth() + 1, // JavaScript months are 0-indexed
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Predict end-of-month usage based on current usage pattern
|
|
28
|
+
* Uses linear extrapolation from current usage
|
|
29
|
+
*/
|
|
30
|
+
export function predictMonthlyUsage(
|
|
31
|
+
currentUsage: number,
|
|
32
|
+
currentDay: number,
|
|
33
|
+
daysInMonth: number
|
|
34
|
+
): number {
|
|
35
|
+
if (currentDay <= 0) return currentUsage
|
|
36
|
+
const dailyAverage = currentUsage / currentDay
|
|
37
|
+
return Math.round(dailyAverage * daysInMonth)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Calculate predicted overage cost
|
|
42
|
+
* @param predictedUsage - Predicted total usage for the month
|
|
43
|
+
* @param quota - Monthly quota limit
|
|
44
|
+
* @param pricePerRequest - Price per premium request (default $0.04)
|
|
45
|
+
*/
|
|
46
|
+
export function predictOverageCost(
|
|
47
|
+
predictedUsage: number,
|
|
48
|
+
quota: number,
|
|
49
|
+
pricePerRequest: number = 0.04
|
|
50
|
+
): number {
|
|
51
|
+
const overage = Math.max(0, predictedUsage - quota)
|
|
52
|
+
return overage * pricePerRequest
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Get usage status level based on percentage
|
|
57
|
+
*/
|
|
58
|
+
export function getUsageLevel(percent: number): "normal" | "warning" | "critical" {
|
|
59
|
+
if (percent >= 90) return "critical"
|
|
60
|
+
if (percent >= 70) return "warning"
|
|
61
|
+
return "normal"
|
|
62
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"forceConsistentCasingInFileNames": true,
|
|
10
|
+
"outDir": "./dist",
|
|
11
|
+
"rootDir": "./src",
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"allowImportingTsExtensions": true,
|
|
15
|
+
"noEmit": true
|
|
16
|
+
},
|
|
17
|
+
"include": ["src/**/*"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|