Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 29 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,37 @@ shows, read from the official usage endpoint.

```
Go usage
5h ──────────────── 0% ↻ 4h 56m
7d ──────────────── 3% ↻ 6d 4h
30d ━━────────────── 10% ↻ 25d 16h
5h ━───────── 8% +10pp ↻ 4h 6m
7d ━━━━────── 42% on pace ↻ 4d 4h
1mo ━───────── 13% on pace ↻ 25d 16h
```

One line per window: label, bar, percentage and time until the window resets.
Bars turn yellow from 75% and red from 90%.
One line per window: label, bar, percentage, pace and time until the window
resets. Columns are fixed width so the rows stay aligned: 3 cells for the
label, 10 for the bar, 4 for the percentage (100% is the widest) and 7 for the
pace indicator. Bars turn yellow from 75% and red from 90%.

### Pace

The pace indicator compares the used percentage with the share of the window
that has already elapsed. Rolling is exactly 5 hours and weekly is exactly
7 days. The monthly window follows the subscription anniversary: it starts one
UTC calendar month before `resetsAt`, so it lasts 28 to 31 days. The start keeps
the UTC time fields of `resetsAt` and clamps the day to the last valid day of the
previous month: 31 March starts on 28 February (29 in leap years), and 31 May starts on 30 April.
Calendar time counts in full, including Saturdays and Sundays.

Deviations are rounded to whole points before the bucket is chosen: a rounded
magnitude of 5 or less is on pace, and anything above that is a reserve or a
deficit based on the sign of the original deviation.

| Label | Meaning | Color |
| --- | --- | --- |
| `+12pp` | Reserve: usage is 12 points below the elapsed share | green |
| `on pace` | Rounded difference is at most 5 points | cyan |
| `-12pp` | Deficit: usage is 12 points above the elapsed share | yellow |

A missing or invalid reset timestamp hides the pace label.

## Requirements

Expand Down
4 changes: 2 additions & 2 deletions src/format.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { clampPercent } from "./usage.ts"

/** Width of the drawing area in characters. */
export const BAR_WIDTH = 16
/** Width of the drawing area in characters. Fixed so rows stay aligned. */
export const BAR_WIDTH = 10

const FILLED = "\u2501" // ━
const EMPTY = "\u2500" // ─
Expand Down
112 changes: 112 additions & 0 deletions src/pace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* Pace indicator: compare used percentage against the share of the window that
* has already elapsed.
*
* Rolling is exactly 5 hours and weekly is exactly 7 days. Monthly follows the
* subscription anniversary, so its length is one UTC calendar month (28 to 31
* days). The monthly start is the reset timestamp minus one UTC calendar month,
* with the day clamped to the last valid day of the previous month. Only UTC
* fields are used, so local time and daylight saving transitions do not shift
* the result.
*
* Positive values mean a reserve (used less than the elapsed share), negative
* values mean a deficit (used more). Every elapsed second counts, including
* Saturdays and Sundays.
*/
import type { UsageWindowKind } from "./usage.ts"

/** Percentage points either way before the indicator leaves "on pace". */
export const PACE_TOLERANCE = 5

const ROLLING_WINDOW_MS = 5 * 3_600_000
const WEEKLY_WINDOW_MS = 7 * 86_400_000

export type Pace =
| { kind: "reserve"; delta: number }
| { kind: "deficit"; delta: number }
| { kind: "on-pace" }

type PaceInput = {
/** Used percentage reported by the API. */
usedPercent: number
/** ISO timestamp of the window reset. */
resetsAt: string | undefined
/** Length policy of the window: fixed 5h, fixed 7d, or one UTC calendar month. */
windowKind: UsageWindowKind
/** Current time in epoch milliseconds. */
now: number
}

/**
* Start of the monthly window, one UTC calendar month before `resetsMs`.
*
* The day is clamped to the last valid day of that month, so 31 March starts on
* 28 February (29 February in a leap year) and 31 May starts on 30 April. Hours,
* minutes, seconds and milliseconds are preserved.
*/
function monthlyStartMs(resetsMs: number): number {
const resets = new Date(resetsMs)
const year = resets.getUTCFullYear()
const month = resets.getUTCMonth()
const day = resets.getUTCDate()
const lastDayOfPreviousMonth = new Date(Date.UTC(year, month, 0)).getUTCDate()

const start = new Date(resetsMs)
// Move through the first of the month so the current day cannot overflow.
start.setUTCDate(1)
start.setUTCMonth(month - 1)
start.setUTCDate(Math.min(day, lastDayOfPreviousMonth))

return start.getTime()
}

/** Start of the window that ends at `resetsMs`, in epoch milliseconds. */
function windowStartMs(resetsMs: number, kind: UsageWindowKind): number {
if (kind === "rolling") return resetsMs - ROLLING_WINDOW_MS
if (kind === "weekly") return resetsMs - WEEKLY_WINDOW_MS

return monthlyStartMs(resetsMs)
}

/** Length of the window that ends at `resetsMs`, in milliseconds. */
function windowMsOf(resetsMs: number, kind: UsageWindowKind): number {
return resetsMs - windowStartMs(resetsMs, kind)
}

/** Expected share of the budget already spent at `now`, in percent. */
export function expectedPercent(input: Omit<PaceInput, "usedPercent">): number | undefined {
if (input.resetsAt === undefined) return undefined

const resets = Date.parse(input.resetsAt)
if (!Number.isFinite(resets)) return undefined

const windowMs = windowMsOf(resets, input.windowKind)
if (!(windowMs > 0)) return undefined

const start = resets - windowMs
const elapsed = Math.min(windowMs, Math.max(0, input.now - start))

return (elapsed / windowMs) * 100
}

export function paceOf(input: PaceInput): Pace | undefined {
const expected = expectedPercent(input)
if (expected === undefined) return undefined

const delta = input.usedPercent - expected
// Round the magnitude, not the signed value: -5.5 and +5.5 must both leave
// the on-pace band with an absolute deviation of 6 points.
const magnitude = Math.round(Math.abs(delta))

if (magnitude <= PACE_TOLERANCE) return { kind: "on-pace" }

return delta < 0 ? { kind: "reserve", delta: magnitude } : { kind: "deficit", delta: magnitude }
}

/** Fixed-width label: `+12pp`, `-3pp`, `on pace`. */
export function formatPace(pace: Pace | undefined): string {
if (pace === undefined) return ""
if (pace.kind === "on-pace") return "on pace"

return `${pace.kind === "reserve" ? "+" : "-"}${pace.delta}pp`
}
48 changes: 48 additions & 0 deletions tests/format.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test"

import { BAR_WIDTH, bar, remaining, severity } from "../src/format.ts"

describe("characterization: severity thresholds stay unchanged", () => {
test("maps usage onto ok, warn and danger buckets", () => {
expect(severity(0)).toBe("ok")
expect(severity(74)).toBe("ok")
expect(severity(75)).toBe("warn")
expect(severity(89)).toBe("warn")
expect(severity(90)).toBe("danger")
expect(severity(100)).toBe("danger")
})
})

describe("characterization: reset countdown stays unchanged", () => {
const now = Date.parse("2026-10-01T00:00:00Z")

test("formats days, hours, minutes and the boundaries", () => {
expect(remaining(new Date(now + 2 * 86_400_000 + 5 * 3_600_000).toISOString(), now)).toBe("2d 5h")
expect(remaining(new Date(now + 3 * 3_600_000 + 12 * 60_000).toISOString(), now)).toBe("3h 12m")
expect(remaining(new Date(now + 42 * 60_000).toISOString(), now)).toBe("42m")
expect(remaining(new Date(now + 30_000).toISOString(), now)).toBe("<1m")
expect(remaining(new Date(now).toISOString(), now)).toBe("now")
expect(remaining(new Date(now - 1_000).toISOString(), now)).toBe("now")
expect(remaining(undefined, now)).toBe("\u2014")
expect(remaining("not-a-date", now)).toBe("\u2014")
})
})

describe("changed: fixed ten-cell bar", () => {
test("keeps combined filled and empty width at ten cells", () => {
expect(BAR_WIDTH).toBe(10)

for (const percent of [0, 9, 10, 99, 100]) {
const segments = bar(percent)
expect(segments.filled.length + segments.empty.length).toBe(BAR_WIDTH)
}
})

test("rounds the filled cells at the boundary percentages", () => {
expect(bar(0)).toEqual({ filled: "", empty: "\u2500".repeat(10) })
expect(bar(9).filled).toBe("\u2501")
expect(bar(10).filled).toBe("\u2501")
expect(bar(99).filled).toBe("\u2501".repeat(10))
expect(bar(100).filled).toBe("\u2501".repeat(10))
})
})
Loading
Loading