clauddy 1.11.0 → 1.12.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 +25 -10
- package/main.js +84 -6
- package/package.json +6 -4
- package/renderer/index.html +1 -0
- package/renderer/pet.js +85 -0
- package/renderer/style.css +12 -0
package/README.md
CHANGED
|
@@ -9,7 +9,7 @@ A cute pixel-art desktop pet for macOS that tracks your Claude Code usage — mi
|
|
|
9
9
|
|
|
10
10
|
## What it shows
|
|
11
11
|
|
|
12
|
-
- **Current session** — real % used + **"resets in Xh Ym"** + session tokens
|
|
12
|
+
- **Current session** — real % used + **"resets in Xh Ym"** + session tokens, and a projection of where that pace is taking you (see [Burn rate](#burn-rate))
|
|
13
13
|
- **Weekly · all models** — real % used + tokens over the last 7 days
|
|
14
14
|
- **Status line** under the pet: `● working · 1.6M tok/min` (or today's tokens when idle)
|
|
15
15
|
- **By model · 7 days** — Opus / Sonnet / Haiku / Fable, in tokens
|
|
@@ -26,6 +26,19 @@ The session/weekly **%** comes straight from your Anthropic account, so it match
|
|
|
26
26
|
|
|
27
27
|
The token is saved locally (see [Data & privacy](#data--privacy)) and refreshed automatically. **Until you connect**, the limits area shows a _"Connect your account"_ prompt instead of percentages.
|
|
28
28
|
|
|
29
|
+
## Burn rate
|
|
30
|
+
|
|
31
|
+
Knowing you're at **82%** with **1h 12m** left on the window still leaves you doing arithmetic in your head. So Clauddy does it for you: it fits the slope of your recent usage and projects when you'd hit 100% — showing one extra line under the session bar:
|
|
32
|
+
|
|
33
|
+
- **`~35m left at this pace`** (in coral) — you'd run out before the window resets. Ease off, or wrap up.
|
|
34
|
+
- **`resets before you run out`** — the reset gets there first. Carry on.
|
|
35
|
+
|
|
36
|
+
The slope is fitted over your **session tokens** rather than the account %. The % is the number you care about, but it arrives as a whole number every ~5 minutes — over a short window the whole signal is a single `16 → 17` step, which throws the fitted pace off by multiples. Local-log tokens step too — one jump per assistant turn — but in increments some 10–20× finer, so the slope is far steadier; the account % then anchors it, converting tokens into % and re-calibrating on every poll.
|
|
37
|
+
|
|
38
|
+
It reads your **recent** pace, not the session average: go quiet for a few minutes and the projection eases off, which is the point.
|
|
39
|
+
|
|
40
|
+
It only appears once there's enough to say honestly — roughly 5 minutes into a session — and stays hidden while you're idle, when the pace is flat, or right after a reset. A projection is a projection: change your pace and it changes with you.
|
|
41
|
+
|
|
29
42
|
## The pet's states
|
|
30
43
|
|
|
31
44
|
<table>
|
|
@@ -109,9 +122,7 @@ The quickest path works the same as macOS — with [Bun](https://bun.sh) or Node
|
|
|
109
122
|
bunx clauddy # or: npx clauddy
|
|
110
123
|
```
|
|
111
124
|
|
|
112
|
-
Prefer a standalone app with no Node/Bun? Grab the **portable zip** (`Clauddy-<version>-win.zip`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), unzip it anywhere, and run `Clauddy.exe`. Because the app is unsigned, Windows **SmartScreen** shows a "Windows protected your PC" prompt the first time — click **More info → Run anyway**. From then on it starts with Windows.
|
|
113
|
-
|
|
114
|
-
> Windows builds are produced by the **Build** workflow (Actions ▸ Build) — attaching them to every release automatically is on the roadmap.
|
|
125
|
+
Prefer a standalone app with no Node/Bun? Grab the **portable zip** (`Clauddy-<version>-win-x64.zip`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), unzip it anywhere, and run `Clauddy.exe`. Because the app is unsigned, Windows **SmartScreen** shows a "Windows protected your PC" prompt the first time — click **More info → Run anyway**. From then on it starts with Windows.
|
|
115
126
|
|
|
116
127
|
### Linux (x64)
|
|
117
128
|
|
|
@@ -121,15 +132,13 @@ The quickest path works the same as macOS — with [Bun](https://bun.sh) or Node
|
|
|
121
132
|
bunx clauddy # or: npx clauddy
|
|
122
133
|
```
|
|
123
134
|
|
|
124
|
-
Prefer a standalone app? Grab the **AppImage** or **tar.gz** (`Clauddy-<version
|
|
135
|
+
Prefer a standalone app? Grab the **AppImage** or **tar.gz** (`Clauddy-<version>-linux-x64.AppImage` / `Clauddy-<version>-linux-x64.tar.gz`) from the [latest release](https://github.com/renatoaug/claude-usage-monitor/releases), then:
|
|
125
136
|
|
|
126
137
|
```bash
|
|
127
138
|
chmod +x Clauddy-*.AppImage
|
|
128
139
|
./Clauddy-*.AppImage
|
|
129
140
|
```
|
|
130
141
|
|
|
131
|
-
> Linux builds are produced by the **Build** workflow (Actions ▸ Build) — attaching them to every release automatically is on the roadmap.
|
|
132
|
-
|
|
133
142
|
> The system tray icon needs an indicator extension on vanilla GNOME (e.g. "AppIndicator and KStatusNotifier Item Support") — it works out of the box on Cinnamon, KDE, and XFCE. Autostart-at-login is wired up via an XDG `.desktop` entry in `~/.config/autostart/`.
|
|
134
143
|
|
|
135
144
|
> The app keeps its data in `~/.claude-usage-monitor`, regardless of platform or how you run it.
|
|
@@ -218,9 +227,15 @@ Nothing leaves your machine except the OAuth calls to Anthropic's own login and
|
|
|
218
227
|
Releases are **fully automated**. Every push to `main` runs
|
|
219
228
|
[semantic-release](https://semantic-release.gitbook.io) (`.github/workflows/release.yml`):
|
|
220
229
|
it reads the **Conventional Commits** and, when there's something to ship,
|
|
221
|
-
computes the version, builds the macOS
|
|
222
|
-
|
|
223
|
-
your PRs.
|
|
230
|
+
computes the version, builds the app for **macOS, Windows and Linux** on their
|
|
231
|
+
own runners, publishes `clauddy` to npm, and cuts a GitHub Release with every
|
|
232
|
+
artifact attached. Nothing to do by hand — just merge your PRs.
|
|
233
|
+
|
|
234
|
+
The pipeline runs in three stages, because electron-builder can't cross-build
|
|
235
|
+
Windows/Linux from macOS: `version` (a semantic-release dry-run that computes
|
|
236
|
+
the next version) → `build` (a matrix that stamps that version into
|
|
237
|
+
`package.json` so the filenames are right) → `publish` (downloads every
|
|
238
|
+
artifact and runs semantic-release for real).
|
|
224
239
|
|
|
225
240
|
- `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → major.
|
|
226
241
|
- `docs:`/`chore:`/`ci:` etc. don't trigger a release.
|
package/main.js
CHANGED
|
@@ -36,6 +36,9 @@ if (process.env.CLAUDE_CONFIG_DIR) {
|
|
|
36
36
|
const EXTERNAL_CONFIG = path.join(DATA_DIR, 'config.json')
|
|
37
37
|
// debug channel: `./pet <state>` writes here to force a state (dev only)
|
|
38
38
|
const DEBUG_FILE = path.join(DATA_DIR, 'debug.json')
|
|
39
|
+
// remembered floating-widget position: survives restarts and lets the widget
|
|
40
|
+
// return to the monitor the user parked it on after a display is unplugged/replugged
|
|
41
|
+
const WINDOW_STATE = path.join(DATA_DIR, 'window.json')
|
|
39
42
|
|
|
40
43
|
let win
|
|
41
44
|
let pollTimer
|
|
@@ -49,6 +52,8 @@ let currentMode = 'floating' // 'floating' widget | 'menubar' popover
|
|
|
49
52
|
let trayBounds = null // last known tray icon rect, to anchor the popover
|
|
50
53
|
let lastBlurHide = 0 // debounce: ignore the tray click that dismissed the popover
|
|
51
54
|
let sessionPct = null // authoritative session % shown in the tray title
|
|
55
|
+
let lastProgrammaticMove = 0 // ignore the 'moved' event our own setPosition triggers
|
|
56
|
+
let displayChanging = 0 // ignore OS window-shuffles while a display (dis)connects
|
|
52
57
|
|
|
53
58
|
function publicConfig(c) {
|
|
54
59
|
return {
|
|
@@ -151,6 +156,27 @@ function createWindow() {
|
|
|
151
156
|
}
|
|
152
157
|
})
|
|
153
158
|
|
|
159
|
+
// remember where the user parks the widget — but not the moves we make
|
|
160
|
+
// ourselves (resize re-anchoring) nor the ones the OS forces when a display
|
|
161
|
+
// (dis)connects, so a monitor going dark never overwrites the saved spot
|
|
162
|
+
win.on('moved', () => {
|
|
163
|
+
if (Date.now() - lastProgrammaticMove < 500) return
|
|
164
|
+
if (Date.now() - displayChanging < 2000) return
|
|
165
|
+
saveWindowState()
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
// when a monitor is unplugged/replugged (or its layout changes), put the
|
|
169
|
+
// floating widget back on the display the user parked it on — the OS dumps
|
|
170
|
+
// it on the primary display otherwise, and never moves it back on its own
|
|
171
|
+
const onDisplayChange = () => {
|
|
172
|
+
displayChanging = Date.now()
|
|
173
|
+
if (currentMode === 'floating' && win && !win.isDestroyed() && win.isVisible())
|
|
174
|
+
positionFloating()
|
|
175
|
+
}
|
|
176
|
+
screen.on('display-added', onDisplayChange)
|
|
177
|
+
screen.on('display-removed', onDisplayChange)
|
|
178
|
+
screen.on('display-metrics-changed', onDisplayChange)
|
|
179
|
+
|
|
154
180
|
const tick = () => {
|
|
155
181
|
if (!win || win.isDestroyed()) return
|
|
156
182
|
try {
|
|
@@ -250,6 +276,43 @@ function showPopover() {
|
|
|
250
276
|
win.focus()
|
|
251
277
|
}
|
|
252
278
|
|
|
279
|
+
// route every programmatic move through here so the 'moved' handler can tell
|
|
280
|
+
// our own repositioning apart from a genuine user drag (and skip saving it)
|
|
281
|
+
function moveWindow(x, y) {
|
|
282
|
+
lastProgrammaticMove = Date.now()
|
|
283
|
+
win.setPosition(Math.round(x), Math.round(y))
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// persist the widget's anchor — its bottom-right corner, since the window's
|
|
287
|
+
// size changes as the pet animates — so it can be restored later
|
|
288
|
+
function saveWindowState() {
|
|
289
|
+
if (currentMode !== 'floating' || !win || win.isDestroyed()) return
|
|
290
|
+
const b = win.getBounds()
|
|
291
|
+
try {
|
|
292
|
+
fs.mkdirSync(DATA_DIR, { recursive: true })
|
|
293
|
+
fs.writeFileSync(WINDOW_STATE, JSON.stringify({ right: b.x + b.width, bottom: b.y + b.height }))
|
|
294
|
+
} catch {}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function loadWindowState() {
|
|
298
|
+
try {
|
|
299
|
+
const s = JSON.parse(fs.readFileSync(WINDOW_STATE, 'utf8'))
|
|
300
|
+
return Number.isFinite(s?.right) && Number.isFinite(s?.bottom) ? s : null
|
|
301
|
+
} catch {
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// is the saved bottom-right corner on a display that's currently connected?
|
|
307
|
+
function cornerVisible(right, bottom) {
|
|
308
|
+
const x = Math.round(right - 1)
|
|
309
|
+
const y = Math.round(bottom - 1)
|
|
310
|
+
return screen.getAllDisplays().some((d) => {
|
|
311
|
+
const b = d.bounds
|
|
312
|
+
return x >= b.x && x < b.x + b.width && y >= b.y && y < b.y + b.height
|
|
313
|
+
})
|
|
314
|
+
}
|
|
315
|
+
|
|
253
316
|
// center the popover under the tray icon, kept on-screen
|
|
254
317
|
function positionUnderTray() {
|
|
255
318
|
const b = win.getBounds()
|
|
@@ -261,13 +324,20 @@ function positionUnderTray() {
|
|
|
261
324
|
y = Math.round(trayBounds.y + trayBounds.height)
|
|
262
325
|
}
|
|
263
326
|
x = Math.max(8, Math.min(x, workAreaSize.width - b.width - 8))
|
|
264
|
-
|
|
327
|
+
moveWindow(x, y)
|
|
265
328
|
}
|
|
266
329
|
|
|
267
330
|
function positionFloating() {
|
|
268
|
-
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
269
331
|
const b = win.getBounds()
|
|
270
|
-
|
|
332
|
+
// reuse the saved spot when that monitor is still connected; otherwise fall
|
|
333
|
+
// back to the primary display's bottom-right corner
|
|
334
|
+
const saved = loadWindowState()
|
|
335
|
+
if (saved && cornerVisible(saved.right, saved.bottom)) {
|
|
336
|
+
moveWindow(saved.right - b.width, saved.bottom - b.height)
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
const { workAreaSize } = screen.getPrimaryDisplay()
|
|
340
|
+
moveWindow(workAreaSize.width - b.width - 24, workAreaSize.height - b.height - 24)
|
|
271
341
|
}
|
|
272
342
|
|
|
273
343
|
// the tray shows the live session % (macOS title), turning 🔥 near the limit
|
|
@@ -296,12 +366,20 @@ ipcMain.on('resize', (_e, w, h) => {
|
|
|
296
366
|
if (!win || win.isDestroyed()) return
|
|
297
367
|
const width = Math.max(100, Math.round(w))
|
|
298
368
|
const height = Math.max(110, Math.round(h))
|
|
299
|
-
win.setContentSize(width, height)
|
|
300
369
|
if (currentMode === 'menubar') {
|
|
370
|
+
win.setContentSize(width, height)
|
|
301
371
|
if (win.isVisible()) positionUnderTray() // keep it anchored under the tray
|
|
302
372
|
} else {
|
|
303
|
-
|
|
304
|
-
|
|
373
|
+
// keep the widget pinned to its current bottom-right corner on whatever
|
|
374
|
+
// display the user dragged it to. setContentSize grows from the top-left
|
|
375
|
+
// origin, so re-anchor by the old corner instead of snapping to the primary
|
|
376
|
+
// display's bottom-right (which yanked the widget back on every update).
|
|
377
|
+
const before = win.getBounds()
|
|
378
|
+
const right = before.x + before.width
|
|
379
|
+
const bottom = before.y + before.height
|
|
380
|
+
win.setContentSize(width, height)
|
|
381
|
+
const after = win.getBounds()
|
|
382
|
+
moveWindow(right - after.width, bottom - after.height)
|
|
305
383
|
}
|
|
306
384
|
})
|
|
307
385
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "clauddy",
|
|
3
3
|
"desktopName": "clauddy.desktop",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.12.0",
|
|
5
5
|
"description": "A cute desktop pet that tracks your Claude Code usage",
|
|
6
6
|
"main": "main.js",
|
|
7
7
|
"bin": {
|
|
@@ -80,17 +80,20 @@
|
|
|
80
80
|
"category": "public.app-category.developer-tools",
|
|
81
81
|
"target": "dir",
|
|
82
82
|
"identity": null,
|
|
83
|
-
"icon": "build/icon.icns"
|
|
83
|
+
"icon": "build/icon.icns",
|
|
84
|
+
"artifactName": "${productName}-${version}-mac-${arch}.${ext}"
|
|
84
85
|
},
|
|
85
86
|
"win": {
|
|
86
87
|
"target": "zip",
|
|
87
|
-
"icon": "build/icon.ico"
|
|
88
|
+
"icon": "build/icon.ico",
|
|
89
|
+
"artifactName": "${productName}-${version}-win-${arch}.${ext}"
|
|
88
90
|
},
|
|
89
91
|
"linux": {
|
|
90
92
|
"target": [
|
|
91
93
|
"tar.gz",
|
|
92
94
|
"AppImage"
|
|
93
95
|
],
|
|
96
|
+
"artifactName": "${productName}-${version}-linux-${arch}.${ext}",
|
|
94
97
|
"category": "Utility",
|
|
95
98
|
"icon": "build/icon.png",
|
|
96
99
|
"syncDesktopName": true
|
|
@@ -101,7 +104,6 @@
|
|
|
101
104
|
},
|
|
102
105
|
"devDependencies": {
|
|
103
106
|
"@biomejs/biome": "^2.5.0",
|
|
104
|
-
"@semantic-release/exec": "^7.1.0",
|
|
105
107
|
"electron-builder": "^26.15.3",
|
|
106
108
|
"semantic-release": "^25.0.5"
|
|
107
109
|
}
|
package/renderer/index.html
CHANGED
|
@@ -246,6 +246,7 @@
|
|
|
246
246
|
<div class="meter-top"><span>current session</span><span id="session-pct">0%</span></div>
|
|
247
247
|
<div class="track"><div class="fill" id="session-fill"></div></div>
|
|
248
248
|
<div class="sub" id="session-sub">no active session</div>
|
|
249
|
+
<div class="sub proj" id="session-proj" hidden></div>
|
|
249
250
|
</div>
|
|
250
251
|
<div class="meter">
|
|
251
252
|
<div class="meter-top"><span>weekly · all models</span><span id="week-pct">0%</span></div>
|
package/renderer/pet.js
CHANGED
|
@@ -154,6 +154,80 @@ function fmtReset(ms) {
|
|
|
154
154
|
const m = Math.floor((ms % 3600000) / 60000)
|
|
155
155
|
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
|
156
156
|
}
|
|
157
|
+
// ---- burn-rate projection ----
|
|
158
|
+
// The panel knows where you are (82%) and when the window resets (1h 12m); it
|
|
159
|
+
// can also say where you're headed — fit a slope through recent usage and
|
|
160
|
+
// project the crossing of 100%. If the reset lands first there's nothing to
|
|
161
|
+
// worry about, which is worth saying out loud rather than leaving blank.
|
|
162
|
+
//
|
|
163
|
+
// The slope is fitted over session *tokens*, not the account %. The % is the
|
|
164
|
+
// number we ultimately care about, but it arrives as a whole number every ~5
|
|
165
|
+
// min: over a short window the whole signal is a single 16 → 17 step, which
|
|
166
|
+
// makes the fitted pace wrong by multiples. Local-log tokens step too — one
|
|
167
|
+
// jump per assistant turn, with plateaus in between — but in increments some
|
|
168
|
+
// 10-20x finer, so they carry a far steadier slope. The
|
|
169
|
+
// account % still anchors it: pct/tokens converts tokens/ms into %/ms and
|
|
170
|
+
// re-calibrates on every poll, so the projection stays tied to the real number.
|
|
171
|
+
const PROJ_WINDOW_MS = 45 * 60 * 1000 // only fit recent samples — pace changes
|
|
172
|
+
const PROJ_MIN_SAMPLES = 4
|
|
173
|
+
const PROJ_MIN_SPAN_MS = 5 * 60 * 1000 // shorter than a % fit affords, and steadier
|
|
174
|
+
const PROJ_SAMPLE_EVERY_MS = 30 * 1000 // usage polls every few seconds; thin it out
|
|
175
|
+
let tokTrail = []
|
|
176
|
+
|
|
177
|
+
function noteTokens(tokens, active) {
|
|
178
|
+
if (!active || !(tokens > 0)) {
|
|
179
|
+
tokTrail = [] // no session, no trend
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
// session tokens only climb within a window — a drop means it rolled over,
|
|
183
|
+
// and the fresh sample starts the new trail rather than being throttled away
|
|
184
|
+
let last = tokTrail[tokTrail.length - 1]
|
|
185
|
+
if (last && tokens < last.tokens) {
|
|
186
|
+
tokTrail = []
|
|
187
|
+
last = undefined
|
|
188
|
+
}
|
|
189
|
+
const now = Date.now()
|
|
190
|
+
if (last && now - last.t < PROJ_SAMPLE_EVERY_MS) return
|
|
191
|
+
tokTrail.push({ t: now, tokens })
|
|
192
|
+
tokTrail = tokTrail.filter((s) => s.t >= now - PROJ_WINDOW_MS)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// least-squares slope in tokens per ms, or null when there isn't enough to say
|
|
196
|
+
function burnSlope() {
|
|
197
|
+
if (tokTrail.length < PROJ_MIN_SAMPLES) return null
|
|
198
|
+
const span = tokTrail[tokTrail.length - 1].t - tokTrail[0].t
|
|
199
|
+
if (span < PROJ_MIN_SPAN_MS) return null
|
|
200
|
+
const n = tokTrail.length
|
|
201
|
+
const t0 = tokTrail[0].t
|
|
202
|
+
let sx = 0
|
|
203
|
+
let sy = 0
|
|
204
|
+
let sxy = 0
|
|
205
|
+
let sxx = 0
|
|
206
|
+
for (const s of tokTrail) {
|
|
207
|
+
const x = s.t - t0
|
|
208
|
+
sx += x
|
|
209
|
+
sy += s.tokens
|
|
210
|
+
sxy += x * s.tokens
|
|
211
|
+
sxx += x * x
|
|
212
|
+
}
|
|
213
|
+
const denom = n * sxx - sx * sx
|
|
214
|
+
if (denom === 0) return null
|
|
215
|
+
return (n * sxy - sx * sy) / denom
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// → { kind: 'eta', ms } when you'd run out first, { kind: 'safe' } when the
|
|
219
|
+
// reset beats you there, or null when we can't tell yet.
|
|
220
|
+
function project(pct, resetMs, tokens) {
|
|
221
|
+
if (!(pct > 0) || !(tokens > 0)) return null // no anchor to convert tokens → %
|
|
222
|
+
const tokPerMs = burnSlope()
|
|
223
|
+
if (tokPerMs == null || tokPerMs <= 0) return null // idle or flat
|
|
224
|
+
const pctPerMs = tokPerMs * (pct / tokens)
|
|
225
|
+
const eta = (100 - pct) / pctPerMs
|
|
226
|
+
if (!Number.isFinite(eta) || eta <= 0) return null
|
|
227
|
+
if (resetMs != null && eta >= resetMs) return { kind: 'safe' }
|
|
228
|
+
return { kind: 'eta', ms: eta }
|
|
229
|
+
}
|
|
230
|
+
|
|
157
231
|
function setState(name) {
|
|
158
232
|
const b = document.body
|
|
159
233
|
;[...b.classList].forEach((c) => {
|
|
@@ -500,6 +574,17 @@ function render(d) {
|
|
|
500
574
|
? `resets in ${fmtReset(sessReset)} · ${fmtTokens(d.session.tokens)} tokens`
|
|
501
575
|
: 'no active session'
|
|
502
576
|
|
|
577
|
+
// where this pace is taking you — hidden until there's enough trail to tell
|
|
578
|
+
noteTokens(d.session.tokens, sessActive)
|
|
579
|
+
const proj = sessActive && sessPct < 100 ? project(sessPct, sessReset, d.session.tokens) : null
|
|
580
|
+
const pe = el('session-proj')
|
|
581
|
+
pe.hidden = !proj
|
|
582
|
+
pe.classList.toggle('tight', proj?.kind === 'eta')
|
|
583
|
+
if (proj) {
|
|
584
|
+
pe.textContent =
|
|
585
|
+
proj.kind === 'eta' ? `~${fmtReset(proj.ms)} left at this pace` : 'resets before you run out'
|
|
586
|
+
}
|
|
587
|
+
|
|
503
588
|
el('week-pct').textContent = `${Math.round(wkPct)}%`
|
|
504
589
|
const wf = el('week-fill')
|
|
505
590
|
wf.style.width = `${wkPct}%`
|
package/renderer/style.css
CHANGED
|
@@ -1618,6 +1618,18 @@ body.state-tired #status-text {
|
|
|
1618
1618
|
letter-spacing: 0.2px;
|
|
1619
1619
|
}
|
|
1620
1620
|
|
|
1621
|
+
/* burn-rate projection — calm by default, warm when you'd run out first */
|
|
1622
|
+
.sub.proj {
|
|
1623
|
+
margin-top: 2px;
|
|
1624
|
+
font-style: italic;
|
|
1625
|
+
opacity: 0.85;
|
|
1626
|
+
}
|
|
1627
|
+
.sub.proj.tight {
|
|
1628
|
+
color: var(--coral);
|
|
1629
|
+
font-style: normal;
|
|
1630
|
+
opacity: 1;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1621
1633
|
/* 30-day map */
|
|
1622
1634
|
#heat {
|
|
1623
1635
|
margin-top: 12px;
|