opencode-tweaks 0.0.0-stage → 0.4.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 FURYMOB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,223 @@
1
- # Temporary Holding Version
1
+ # opencode-tweaks
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![CI](https://github.com/furymob-git/opencode-tweaks/actions/workflows/ci.yml/badge.svg)](https://github.com/furymob-git/opencode-tweaks/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
5
+
6
+ Settings and small interface improvements for [OpenCode](https://opencode.ai), collected in one place.
7
+
8
+ OpenCode is already comfortable out of the box. This plugin is for the details you may still want to tune: a command
9
+ that is missing, a keybinding that does not fit your workflow, a behavior you would rather switch off. Every tweak is
10
+ optional, and the defaults stay exactly where they are.
11
+
12
+ ## Status
13
+
14
+ Early development. The `/tweaks` menu and the OpenCode Go quota sidebar are in place. Both the tweak list and the
15
+ configuration format may change before the first stable release.
16
+
17
+ ## Principles
18
+
19
+ - **Sensible defaults.** Tweaks are on out of the box, tuned to the setup that works best day to day. Every one has a
20
+ switch in `/tweaks` when you want OpenCode untouched.
21
+ - **Native first.** If OpenCode already handles something well, this plugin does not replace it.
22
+ - **Public API only.** No forks, patches, or private imports. Everything goes through `@opencode/plugin`.
23
+ - **Small surface.** Each tweak is isolated and documented, so you can see exactly what it changes.
24
+
25
+ ## Requirements
26
+
27
+ - OpenCode 2.x
28
+ - Node.js 24 or newer for local development
29
+
30
+ ## Install
31
+
32
+ ```sh
33
+ opencode plugin add opencode-tweaks
34
+ ```
35
+
36
+ Or let OpenCode install and verify it for you. Paste this prompt into a session:
37
+
38
+ ```text
39
+ Install the opencode-tweaks plugin on this machine.
40
+
41
+ - Run `opencode --version` first and stop with a clear message if it is not 2.x.
42
+ - Run `opencode plugin add opencode-tweaks`.
43
+ - Run `opencode plugin list` and confirm opencode-tweaks is registered.
44
+ - Tell me the plugin version, the config file that changed, and anything I need to restart.
45
+ - Do not edit my existing settings or plugin options.
46
+ ```
47
+
48
+ To add it by hand, put it in the `plugins` list in `opencode.json`:
49
+
50
+ ```jsonc
51
+ {
52
+ "$schema": "https://opencode.ai/config.json",
53
+ "plugins": ["opencode-tweaks"]
54
+ }
55
+ ```
56
+
57
+ To follow the source instead, point the same list at a local checkout, such as `"../opencode-tweaks"`. Relative paths
58
+ resolve from the config file that contains the entry and must point at a directory.
59
+
60
+ ## Settings
61
+
62
+ Run `/tweaks` to open the settings menu. It mirrors OpenCode's own settings dialog: the same theme, grouped rows, search
63
+ field, and footer hints. The tweak list opens first; selecting one shows its settings. Boolean settings toggle in place;
64
+ other settings cycle through their values with `enter` or `←`/`→`. Press `ctrl+r` to reset the selected setting.
65
+
66
+ The command also appears in the palette under `Tweaks`. Its id is `opencode-tweaks.tweaks` if you want to bind a key in
67
+ `cli.json`.
68
+
69
+ Values are resolved in this order:
70
+
71
+ 1. a value changed from `/tweaks` and stored locally by the plugin
72
+ 2. an option in `opencode.json`
73
+ 3. the default in the tweak definition
74
+
75
+ Options are useful when you want the same configuration on several machines:
76
+
77
+ ```jsonc
78
+ {
79
+ "$schema": "https://opencode.ai/config.json",
80
+ "plugins": [
81
+ {
82
+ "package": "opencode-tweaks",
83
+ "options": {
84
+ "quota": {
85
+ "enabled": true,
86
+ "barWidth": 30
87
+ }
88
+ }
89
+ }
90
+ ]
91
+ }
92
+ ```
93
+
94
+ Resetting a setting drops the local value, so the option or default applies again.
95
+
96
+ ## Tweaks
97
+
98
+ | Tweak | What it does | Default |
99
+ | -------- | ----------------------------------------------------------------- | ------- |
100
+ | `quota` | Provider quota, reset times, and a daily spending budget. | On |
101
+ | `tokens` | Token and cost usage per turn, plus session totals at the bottom. | On |
102
+
103
+ ### Quota
104
+
105
+ Quota shows the OpenCode Go limits for the 5h, 7d, and 30d windows. It uses the active OpenCode Console sign-in when
106
+ there is one, then an `opencode-go` API key. Sign in with `opencode auth login opencode` or
107
+ `opencode auth login opencode-go` before enabling the tweak.
108
+
109
+ The panel refreshes every two minutes, and a failed read retries after twenty seconds and keeps the last rows. Click the
110
+ `Quota` header to collapse or expand the rows. Bar width defaults to `auto`, which uses the space the sidebar leaves
111
+ after the percent and reset columns. A fixed width is available from `/tweaks`.
112
+
113
+ `Percent decimals` chooses 0, 1, or 2 decimals for every percentage, the `day` row included. The Console endpoints
114
+ publish a finer share than a whole percent, so the figures move between points instead of jumping a whole one; a source
115
+ that only reports whole percents shows the same value either way.
116
+
117
+ The provider name sits on the `Quota` line by default and can be aligned left, center, or right. Turning
118
+ `One tab per provider` off gives it a line of its own, above the rows that collapse underneath.
119
+
120
+ `Current provider only` is on by default. Each quota source is registered on the server under the provider ID the model
121
+ catalog uses, so the panel names no provider itself and follows the model in use: it appears for a provider whose quota
122
+ the plugin can read, and stays hidden for one that publishes none. Turn the setting off to show every quota the plugin
123
+ knows at once. OpenCode Go is the only source today; adding another is a reader and one entry in the registry.
124
+
125
+ `Daily budget` adds a `day` row under the longest window: the share of that quota set aside for today, spread over the
126
+ workdays left before it resets. The bar starts full each morning and empties as the provider reports less quota, so you
127
+ see how much of today's share is left. It takes the theme accent color and turns orange near the end, then red once the
128
+ share is spent. The figures are frozen at the first reading of the day, so the bar only moves when quota is used, not
129
+ on every refresh.
130
+
131
+ `Reserve` keeps a percentage of the window out of the daily budget, so the month does not end at zero. The `Workdays`
132
+ settings choose which weekdays count; every day counts by default, and a day left off gets no budget and shows `rest` on
133
+ the row. The budget resets at local midnight, and a changed provider reset or workday pattern starts a fresh one.
134
+
135
+ ### Tokens
136
+
137
+ Tokens shows two token counts and, optionally, their cost. Each has its own toggle.
138
+
139
+ `Turn tokens` adds what the latest finished turn spent to its `tok/s` line: `12.3K tok · 62.2 tok/s`. OpenCode draws
140
+ that line itself and publishes no slot inside it, so the tweak splices the value into the line's text node and repairs
141
+ the splice while a turn runs. That placement is best effort and may lag or break when OpenCode changes the line. When the
142
+ splice is not available the count stays out of the line rather than moving elsewhere.
143
+
144
+ The line belongs to the turn that finished above it, so each line keeps its own figures: while the next turn runs, the
145
+ previous line still shows the previous turn's numbers rather than the running one's. When a turn finishes, its line
146
+ receives its figures and the older lines keep theirs.
147
+
148
+ `First token time` controls the host's own `1.7s ttft` figure at the end of that line: it is off by default, so the
149
+ span is removed and `tok/s` stays alone. Turning it on restores the host's figure.
150
+
151
+ `Turn cost` adds that turn's cost after the throughput figure: `62.2 tok/s · $0.0042`.
152
+
153
+ `Session tokens` adds the session's total usage to the bottom status line, before the host's context figure:
154
+ `1 shell · 790.6K tok total · $0.91 · 115K (29%)`. It reads the counters the session itself keeps, so
155
+ the number stays exact even when the transcript has only loaded its latest messages. The figures follow the shell
156
+ indicator when one is running and otherwise open the context text, so the order stays shell, total, session cost,
157
+ context. OpenCode draws those texts itself and publishes no slot inside them, so the figures are spliced in and
158
+ repaired while the line changes; when the splice is not available the figures stay out of the line rather than
159
+ moving elsewhere.
160
+
161
+ `Token count` picks what the two numbers add up. `Generated only` is the default: it keeps just what the model wrote.
162
+ `New tokens` adds prompt, completion, reasoning, and cache writes, without cache reads, because a turn re-reads the
163
+ whole context at every step and those reads would dwarf everything else. `Everything` matches the counters `/stats`
164
+ reports, cache reads included. `Include subagents` is on by default, so the session totals cover the whole session tree
165
+ instead of only the session on screen; the per-turn count always stays on the session on screen, because a prompt
166
+ belongs to it. `Token format` switches between compact counts such as `12.4K` and every digit such as `12,431`.
167
+
168
+ Costs come from what the provider reports, so a free plan shows `$0.00`. `Cost decimals` controls the decimals:
169
+ `Auto` keeps two decimals and grows until the first significant digit shows (`$0.004`), while a fixed choice always
170
+ keeps that many (`$0.0042`).
171
+
172
+ `Estimate cost` fills in a price the provider did not report, using the model's list price from the same catalog
173
+ OpenCode already synced: prompt, completion, reasoning billed as output, and cache traffic, each at its own rate. A
174
+ provider that reports a price always wins, and a session that changed model is estimated with the model it ended on, so
175
+ treat the figure as an estimate. This is what makes the OpenCode Go monthly credit readable: the plan reports `$0.00`
176
+ per turn, and the estimate follows the API rates instead. Costs use the raw counters whatever `Token count` says.
177
+
178
+ ## Development
179
+
180
+ ```sh
181
+ npm install
182
+ npm run check
183
+ ```
184
+
185
+ `npm run check` runs the type checker, the linter, and the tests. `npm run format` applies the formatter.
186
+
187
+ OpenCode loads the package from `src`, so there is no build step. To test changes, configure the checkout as a local
188
+ plugin (see Install) and let the config watcher reload it. If a change is not picked up, run `opencode service restart`.
189
+
190
+ ```text
191
+ src/
192
+ index.ts server entry: quota RPC registration
193
+ rpc.ts typed RPC contract
194
+ quota/
195
+ service.ts credential lookup and quota fetch
196
+ parse.ts provider response parsing
197
+ format.ts percents, reset times, and bars
198
+ tui.tsx terminal entry: command and sidebar slot
199
+ tweaks/
200
+ types.ts settings schema and validation
201
+ registry.ts list of tweaks
202
+ settings.ts stored, config, and default resolution
203
+ menu.ts /tweaks entry
204
+ menu-dialog.tsx reactive settings dialog
205
+ menu-model.ts filtering and selection helpers
206
+ quota.ts quota tweak definition
207
+ quota-panel.tsx sidebar quota panel
208
+ tokens.ts tokens tweak definition
209
+ token-slots.tsx inline splice driver and status line
210
+ token-inject.ts splice into the host's tok/s line
211
+ tokens-model.ts token sum and formatting
212
+ ```
213
+
214
+ ## Contributing
215
+
216
+ Tweaks should stay small, opt-in, and built on the public plugin API. If something can be done with normal OpenCode
217
+ configuration, it does not belong here. Open an issue before starting on anything substantial.
218
+
219
+ See [CONTRIBUTING.md](CONTRIBUTING.md) for the branch model, commit format, code rules, and release process.
220
+
221
+ ## License
222
+
223
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,64 @@
1
1
  {
2
+ "$schema": "https://json.schemastore.org/package.json",
2
3
  "name": "opencode-tweaks",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
4
+ "version": "0.4.0",
5
+ "description": "Opt-in settings and small interface improvements for OpenCode.",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "engines": {
9
+ "node": ">=24"
10
+ },
11
+ "author": "FURYMOB",
12
+ "homepage": "https://github.com/furymob-git/opencode-tweaks#readme",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/furymob-git/opencode-tweaks.git"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/furymob-git/opencode-tweaks/issues"
19
+ },
20
+ "keywords": [
21
+ "opencode",
22
+ "opencode-plugin",
23
+ "tui",
24
+ "settings",
25
+ "quota",
26
+ "tokens"
27
+ ],
28
+ "exports": {
29
+ ".": "./src/index.ts",
30
+ "./rpc": "./src/rpc.ts",
31
+ "./tui": "./src/tui.tsx"
32
+ },
33
+ "files": [
34
+ "src",
35
+ "!src/**/*.test.ts"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {
41
+ "typecheck": "tsc --noEmit",
42
+ "lint": "biome check .",
43
+ "format": "biome format --write .",
44
+ "test": "vitest run",
45
+ "check": "npm run typecheck && npm run lint && npm test"
46
+ },
47
+ "dependencies": {
48
+ "@opencode/plugin": "^2.2.7"
49
+ },
50
+ "peerDependencies": {
51
+ "@opentui/core": ">=0.8.1",
52
+ "@opentui/solid": ">=0.8.1",
53
+ "solid-js": ">=1.9.0"
54
+ },
55
+ "devDependencies": {
56
+ "@biomejs/biome": "^2.6.7",
57
+ "@opentui/core": "^0.8.4",
58
+ "@opentui/solid": "^0.8.4",
59
+ "@types/node": "^24",
60
+ "solid-js": "^1.9.19",
61
+ "typescript": "^7.0.2",
62
+ "vitest": "^5.1.0"
63
+ }
64
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { Plugin } from "@opencode/plugin"
2
+ import { readQuotas } from "./quota/service"
3
+ import { Quota } from "./rpc"
4
+
5
+ export default Plugin.define({
6
+ id: "opencode-tweaks",
7
+ async setup(context) {
8
+ const registration = await context.rpc.register(Quota, {
9
+ snapshot: async (input, call) => {
10
+ // JSON Schema methods do not infer their input, so the shape is cast here.
11
+ const { providerID } = input as { providerID?: string }
12
+ return { snapshots: await readQuotas(context, providerID, call.signal) }
13
+ },
14
+ })
15
+
16
+ return async () => {
17
+ await registration.dispose()
18
+ }
19
+ },
20
+ })
@@ -0,0 +1,97 @@
1
+ export type QuotaLevel = "good" | "warning" | "low"
2
+
3
+ const MINUTE = 60_000
4
+
5
+ /**
6
+ * One decimal only when the percent has one: "98%", "99.2%". Passing
7
+ * `decimals` keeps that many instead, so the column can show the finer share a
8
+ * provider publishes.
9
+ */
10
+ export function formatPercent(value: number, decimals?: number): string {
11
+ const clamped = Math.max(0, Math.min(100, value))
12
+
13
+ if (decimals !== undefined) {
14
+ const fixed = Math.max(0, Math.min(2, Math.round(decimals)))
15
+ if (fixed === 0) return `${Math.round(clamped)}%`
16
+ const factor = 10 ** fixed
17
+ return `${(Math.round(clamped * factor) / factor).toFixed(fixed)}%`
18
+ }
19
+
20
+ const rounded = Math.round(clamped * 10) / 10
21
+ return Number.isInteger(rounded) ? `${rounded}%` : `${rounded.toFixed(1)}%`
22
+ }
23
+
24
+ /**
25
+ * The compact reset style from the sidebar: "42m", "4h 9m", "2d 23h", "29d 14h".
26
+ * Minutes stay visible under a day, and days always carry their hours so the
27
+ * column keeps the same shape.
28
+ */
29
+ export function formatReset(iso: string, now: number): string {
30
+ const time = Date.parse(iso)
31
+ if (!Number.isFinite(time) || time <= now) return "now"
32
+
33
+ const minutes = Math.ceil((time - now) / MINUTE)
34
+ if (minutes < 60) return `${minutes}m`
35
+
36
+ const totalHours = Math.floor(minutes / 60)
37
+ const days = Math.floor(totalHours / 24)
38
+
39
+ if (days === 0) {
40
+ const rest = minutes % 60
41
+ return rest === 0 ? `${totalHours}h` : `${totalHours}h ${rest}m`
42
+ }
43
+
44
+ const hours = totalHours % 24
45
+ return hours === 0 ? `${days}d` : `${days}d ${hours}h`
46
+ }
47
+
48
+ /** Filled and empty cells for a line bar. The caller colors each part. */
49
+ export function barParts(percent: number, width: number): { filled: string; empty: string } {
50
+ const cells = Math.max(0, Math.floor(width))
51
+ const clamped = Math.max(0, Math.min(100, percent))
52
+ const filled = Math.round((clamped / 100) * cells)
53
+ return {
54
+ filled: "━".repeat(filled),
55
+ empty: "─".repeat(Math.max(0, cells - filled)),
56
+ }
57
+ }
58
+
59
+ /** Plenty left, getting low, or almost out. */
60
+ export function levelOf(percentRemaining: number): QuotaLevel {
61
+ const left = Math.max(0, Math.min(100, percentRemaining))
62
+ if (left < 20) return "low"
63
+ return left < 50 ? "warning" : "good"
64
+ }
65
+
66
+ function messageOf(value: unknown): string | undefined {
67
+ if (typeof value !== "object" || value === null || !("message" in value)) return undefined
68
+ const message = (value as { message: unknown }).message
69
+ return typeof message === "string" ? message : undefined
70
+ }
71
+
72
+ /**
73
+ * Readable text for anything thrown. The RPC client rejects with plain objects
74
+ * such as `{ type, message }` rather than `Error` instances, so `String(error)`
75
+ * would show "[object Object]" in the panel.
76
+ */
77
+ export function errorText(error: unknown): string {
78
+ const message =
79
+ error instanceof Error
80
+ ? error.message
81
+ : (messageOf(error) ?? (typeof error === "string" ? error : describe(error)))
82
+
83
+ return message.trim().slice(0, 160)
84
+ }
85
+
86
+ function describe(value: unknown): string {
87
+ if (value === undefined || value === null) return "Unknown error"
88
+
89
+ try {
90
+ const json = JSON.stringify(value)
91
+ if (typeof json === "string" && json.length > 2) return json
92
+ } catch {
93
+ // Fall back to the default string conversion below.
94
+ }
95
+
96
+ return String(value)
97
+ }
@@ -0,0 +1,180 @@
1
+ import type { QuotaWindowKey } from "../rpc"
2
+
3
+ /**
4
+ * The longest window is the one a daily budget is planned on. Its nominal
5
+ * length is enough to choose it, because the reset date alone cannot tell a
6
+ * 5h window that just started from a 30d one that is about to reset.
7
+ */
8
+ const WINDOW_DAYS: Readonly<Record<QuotaWindowKey, number>> = {
9
+ "5h": 5 / 24,
10
+ "7d": 7,
11
+ "30d": 30,
12
+ }
13
+
14
+ export interface PaceWindow {
15
+ readonly key: QuotaWindowKey
16
+ readonly percentRemaining: number
17
+ readonly percentRemainingExact?: number
18
+ readonly resetsAt: string
19
+ }
20
+
21
+ /**
22
+ * The share to pace with: the provider's exact figure when it publishes one, so
23
+ * the daily bar moves with real spending instead of a whole percent at a time.
24
+ */
25
+ export function exactRemaining(window: PaceWindow): number {
26
+ return window.percentRemainingExact ?? window.percentRemaining
27
+ }
28
+
29
+ /**
30
+ * The window whose quota is spread over the days: the 30d one when the provider
31
+ * publishes it, the 7d one otherwise, and so on.
32
+ */
33
+ export function longestWindow<Window extends PaceWindow>(
34
+ windows: readonly Window[],
35
+ ): Window | undefined {
36
+ let best: Window | undefined
37
+ for (const window of windows) {
38
+ if (!best || WINDOW_DAYS[window.key] > WINDOW_DAYS[best.key]) best = window
39
+ }
40
+ return best
41
+ }
42
+
43
+ /** Local calendar day as `2026-11-13`: the budget resets with it. */
44
+ export function dayKey(time: number): string {
45
+ const date = new Date(time)
46
+ const month = String(date.getMonth() + 1).padStart(2, "0")
47
+ const day = String(date.getDate()).padStart(2, "0")
48
+ return `${date.getFullYear()}-${month}-${day}`
49
+ }
50
+
51
+ /** Local midnight that follows `time`, when today's budget is replaced. */
52
+ export function endOfDay(time: number): number {
53
+ const date = new Date(time)
54
+ date.setHours(24, 0, 0, 0)
55
+ return date.getTime()
56
+ }
57
+
58
+ /** The weekday a timestamp falls on, with Sunday at index 0 like `Date`. */
59
+ export function weekdayOf(time: number): number {
60
+ return new Date(time).getDay()
61
+ }
62
+
63
+ /**
64
+ * How many selected weekdays are left before the window resets, today
65
+ * included when it is a workday. The reset day counts even when the reset
66
+ * happens early, because that morning still had a budget.
67
+ */
68
+ export function workdaysUntil(
69
+ time: number,
70
+ resetsAt: string,
71
+ workdays: readonly boolean[],
72
+ ): number {
73
+ const reset = Date.parse(resetsAt)
74
+ if (!Number.isFinite(reset) || reset <= time) return 0
75
+
76
+ const cursor = new Date(time)
77
+ cursor.setHours(0, 0, 0, 0)
78
+ const end = new Date(reset)
79
+ end.setHours(0, 0, 0, 0)
80
+
81
+ let count = 0
82
+ let guard = 0
83
+ // A window can never span more than a year, so the guard only stops a
84
+ // malformed reset date from looping.
85
+ while (cursor.getTime() <= end.getTime() && guard < 400) {
86
+ if (workdays[cursor.getDay()] === true) count += 1
87
+ cursor.setDate(cursor.getDate() + 1)
88
+ guard += 1
89
+ }
90
+ return count
91
+ }
92
+
93
+ /** Percent of the window that can be spent on each remaining workday. */
94
+ export function dailyAllowance(
95
+ percentRemaining: number,
96
+ reserve: number,
97
+ workdays: number,
98
+ ): number {
99
+ const spendable = Math.max(0, clamp(percentRemaining) - Math.max(0, reserve))
100
+ if (workdays <= 0) return 0
101
+ return spendable / workdays
102
+ }
103
+
104
+ /** What identifies a budget day, so a changed setting starts a new baseline. */
105
+ export function paceSignature(workdays: readonly boolean[], reserve: number): string {
106
+ return `${workdays.map((day) => (day ? "1" : "0")).join("")}:${reserve}`
107
+ }
108
+
109
+ /**
110
+ * The baseline frozen at the first reading of a budget day. Keeping the start
111
+ * and the allowance still means the bar only moves when quota is spent, instead
112
+ * of shrinking every time the panel reads a lower percentage.
113
+ */
114
+ export interface PaceEntry {
115
+ readonly day: string
116
+ readonly resetsAt: string
117
+ readonly signature: string
118
+ readonly start: number
119
+ readonly allowance: number
120
+ }
121
+
122
+ export function isCurrent(
123
+ entry: PaceEntry | undefined,
124
+ day: string,
125
+ resetsAt: string,
126
+ signature: string,
127
+ ): boolean {
128
+ return (
129
+ entry !== undefined &&
130
+ entry.day === day &&
131
+ entry.resetsAt === resetsAt &&
132
+ entry.signature === signature
133
+ )
134
+ }
135
+
136
+ export function startPace(
137
+ percentRemaining: number,
138
+ day: string,
139
+ resetsAt: string,
140
+ signature: string,
141
+ workdays: readonly boolean[],
142
+ reserve: number,
143
+ now: number,
144
+ ): PaceEntry {
145
+ const restDay = workdays[weekdayOf(now)] !== true
146
+ const allowance = restDay
147
+ ? 0
148
+ : dailyAllowance(percentRemaining, reserve, workdaysUntil(now, resetsAt, workdays))
149
+
150
+ return {
151
+ day,
152
+ resetsAt,
153
+ signature,
154
+ start: clamp(percentRemaining),
155
+ allowance,
156
+ }
157
+ }
158
+
159
+ export interface PaceProgress {
160
+ readonly allowance: number
161
+ readonly spent: number
162
+ readonly left: number
163
+ /** Share of the day's allowance that is left, from 0 to 100. */
164
+ readonly ratio: number
165
+ }
166
+
167
+ export function paceProgress(entry: PaceEntry, percentRemaining: number): PaceProgress {
168
+ const spent = Math.max(0, entry.start - clamp(percentRemaining))
169
+ const left = Math.max(0, entry.allowance - spent)
170
+ const ratio = entry.allowance > 0 ? clamp((left / entry.allowance) * 100) : 0
171
+ return { allowance: entry.allowance, spent, left, ratio }
172
+ }
173
+
174
+ export function isWorkday(time: number, workdays: readonly boolean[]): boolean {
175
+ return workdays[weekdayOf(time)] === true
176
+ }
177
+
178
+ function clamp(value: number): number {
179
+ return Math.max(0, Math.min(100, value))
180
+ }