feat(ccusage): improve statusline labels, add setup command & 2x usage countdown and indicator#892
feat(ccusage): improve statusline labels, add setup command & 2x usage countdown and indicator#892r1di wants to merge 7 commits intoryoppippi:mainfrom
Conversation
…d promotions - Replace cryptic labels (ses/day/blk) with full words (session/today/block) - Add dot separator (·) between cost segments for visual clarity - Separate burn rate from cost block with pipe separator - Add setup-statusline command for auto-configuring Claude Code settings.json - Fix mkdir before writeFileSync to prevent ENOENT on fresh installs - Show promotion countdown during peak hours with separator (· 12d left) - Display session duration and lines changed from hook data - Add formatCompactTokens utility for token count display - Add --promotion-display, --show-session-duration, --show-lines-changed options Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughAdds a timezone-aware promotions module and integrates promotion display into the statusline; introduces a new setup-statusline CLI command to auto-configure Claude Code settings.json; enhances the statusline with tokens/context, session duration, lines-changed, and new CLI flags; adds compact token formatting and platform installer scripts; updates docs and lint-staged config. Changes
Sequence DiagramsequenceDiagram
actor User
participant CLI as "setup-statusline\nCommand"
participant Runtime as "Runner (bun / npx)"
participant FS as "File System"
participant CFG as "settings.json"
User->>CLI: run setup-statusline [--runner|--dry-run|--force...]
CLI->>Runtime: detect/resolve runner (auto -> check bun / fallback npx)
CLI->>CLI: build statusLine command string (flags: visualBurnRate, showPromotions...)
CLI->>FS: determine settings.json path (XDG -> legacy)
CLI->>FS: read settings.json if exists
FS-->>CLI: settings object / not found
alt settings exists and statusLine present and not --force
CLI-->>User: abort (statusLine exists)
else
CLI->>CFG: insert/replace statusLine entry
alt --dry-run
CLI-->>User: print would-be JSON
else
CLI->>FS: ensure directories
CLI->>FS: write settings.json
CLI-->>User: success + restart hint
end
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip You can get early access to new features in CodeRabbit.Enable the |
There was a problem hiding this comment.
Actionable comments posted: 6
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/ccusage/src/commands/setup-statusline.ts`:
- Around line 186-188: The JSON parse of the claude settings file is performed
directly into existingSettings using settingsPath, which will throw on malformed
JSON; wrap the readFileSync/JSON.parse logic in Result.try() (from your Result
utility) and handle the failure by returning or logging a friendly error that
points to settingsPath (e.g., "Malformed settings.json at <settingsPath> —
please fix or remove it") so setup-statusline.ts continues to provide a
recoverable error path (update the code around existingSettings assignment and
any callers that expect the Record to handle the Result failure).
In `@apps/ccusage/src/commands/statusline.ts`:
- Around line 503-510: The fallback currently forces outputTokens: 0 when
calling calculateContextTokens in the Result.try block (using
hookData.transcript_path, hookData.model.id and mergedOptions.offline), which
synthesizes a misleading "↓0"; either update calculateContextTokens to return a
real outputTokens value or stop injecting outputTokens: 0 — return result as-is
(or undefined outputTokens) and update the status-line rendering to omit the
down-arrow segment when outputTokens is absent. Apply the same change for the
similar block at the other occurrence (lines handling the same Result.try path).
- Around line 558-580: The code is reading visibility flags from ctx.values
which ignores values from mergeConfigWithArgs(); change the checks to use the
merged config returned by mergeConfigWithArgs() (or whatever variable holds the
merged config) instead of ctx.values—e.g., replace
ctx.values.showSessionDuration, ctx.values.showLinesChanged,
ctx.values.showPromotions, and ctx.values.promotionDisplay with
mergedConfig.showSessionDuration, mergedConfig.showLinesChanged,
mergedConfig.showPromotions, and mergedConfig.promotionDisplay (and the same
change for the other occurrence around lines 583-591) so the options from config
files are respected.
In `@docs/guide/statusline.md`:
- Around line 144-150: The promotion examples in the statusline docs must be
updated to match the formatter in _promotions.ts which now emits "· Nd left" for
remaining time; replace occurrences of "⚡2x", "⚡2x 12d", and "⚡2x in 2h15m 12d"
with the formatter output (for example use "⚡2x · 12d left" and for peak-mode
"⚡2x · 2h15m left · 12d left"), and update the mode description text
accordingly; apply the same replacements to the other example block mentioned
(lines around 339-348).
- Around line 149-156: Add the plain-text fence language "text" to the three
fenced code blocks shown (the one starting with "Opus 4 | $0.23 session · $1.23
today · $0.45 block 2h45m | $8.50/hr | ↑25K ↓3.2K | 12% ctx | 45m +23 -5 | ⚡2x
in 2h15m 12d", the one starting with "Opus 4 | ($0.25 cc / $0.23 ccusage)
session · $1.23 today · $0.45 block 2h45m | ↑25K ↓3.2K | 12% ctx", and the
additional block referenced at lines 179-181) by changing their opening fences
from ``` to ```text so the Markdown linter (MD040) no longer warns.
In `@packages/terminal/src/table.ts`:
- Around line 368-376: formatCompactTokens currently formats values just below
1_000_000 as "1000.0K" instead of rolling them to "1M"; change the K-branch in
formatCompactTokens to round k to one decimal place (or integer) and then
normalize: if the rounded k is >= 1000, return "1M" (or compute m accordingly),
otherwise return the existing `${k}K`/`${k.toFixed(1)}K` behavior using the
rounded value; ensure the same normalization logic applies for integer vs
one-decimal outputs so values like 999_950 become "1M" instead of "1000.0K".
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 070e1f9a-f74d-48a2-936a-b5755f84c29a
📒 Files selected for processing (6)
apps/ccusage/src/_promotions.tsapps/ccusage/src/commands/index.tsapps/ccusage/src/commands/setup-statusline.tsapps/ccusage/src/commands/statusline.tsdocs/guide/statusline.mdpackages/terminal/src/table.ts
- Wrap JSON.parse in Result.try() for malformed settings.json handling - Remove fake outputTokens:0 in transcript fallback to avoid ↓0 display - Use mergedOptions instead of ctx.values for config-file visibility flags - Fix formatCompactTokens boundary bug at 999,950 tokens (1000.0K → 1M) - Update docs promotion examples to match actual formatter output - Add language tags to fenced code blocks in statusline docs Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/ccusage/src/commands/setup-statusline.ts`:
- Around line 186-199: The parsed settings.json may be a non-object (e.g. array
or string) which later breaks when using object operations; update the parsing
pipeline around existingSettings/settingsPath so after JSON.parse you validate
the result is a plain object (typeof value === 'object' && value !== null &&
!Array.isArray(value)); if validation fails, log the same "Malformed
settings.json" message and treat the result as null/{} (so downstream object
spread and "in" checks are safe). Adjust the Result.try/Result.pipe flow that
produces existingSettings to perform this validation and fallback handling.
In `@apps/ccusage/src/commands/statusline.ts`:
- Around line 34-41: formatRemainingTime currently can produce negative outputs
and prints minutes when they are zero (e.g., "2h0m"); clamp remaining to a
non-negative value, calculate hours and minutes from the clamped value, and when
hours > 0 omit the "0m" suffix (render "2h" instead of "2h0m"); update the
implementation in function formatRemainingTime to enforce remaining =
Math.max(0, remaining), compute remainingHours = Math.floor(remaining/60) and
remainingMins = remaining%60, return `${remainingHours}h` when remainingHours>0
and remainingMins===0, otherwise `${remainingHours}h${remainingMins}m` (or
`${remainingMins}m` when hours===0); apply the same patch to the duplicate
implementation elsewhere in the file (the second formatRemainingTime
occurrence).
- Around line 481-491: The formatContextPercentage helper is reading thresholds
from ctx.values and thus ignores merged config from mergeConfigWithArgs();
update formatContextPercentage to use mergedOptions.contextLowThreshold and
mergedOptions.contextMediumThreshold (or accept mergedOptions as a
closure/captured variable) so rendering uses merged thresholds, and move the
threshold-order validation to run after merging (validate
mergedOptions.contextLowThreshold < mergedOptions.contextMediumThreshold) to
prevent invalid configs; reference the function name formatContextPercentage and
the mergedOptions variables when making the change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 081bc0db-555c-42f1-b875-fafff7cc3870
📒 Files selected for processing (4)
apps/ccusage/src/commands/setup-statusline.tsapps/ccusage/src/commands/statusline.tsdocs/guide/statusline.mdpackages/terminal/src/table.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/terminal/src/table.ts
- Validate parsed settings.json is a plain object (not array/string) - Clamp remaining time to non-negative and omit 0m suffix (2h0m → 2h) - Use mergedOptions for context threshold color calculation - Move threshold validation after config merge for config-file support Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
apps/ccusage/src/commands/statusline.ts (1)
421-470:⚠️ Potential issue | 🟡 MinorUse
mergedOptions.visualBurnRatein this branch too.This block still reads
ctx.values.visualBurnRate, so config-file overrides are ignored unless the flag is passed on the CLI. The rest of the command already switched tomergedOptionsfor the same reason.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/ccusage/src/commands/statusline.ts` around lines 421 - 470, The branch that builds burnRateInfo is reading the CLI-only flag ctx.values.visualBurnRate instead of the effective setting; update the closure inside the burnRate != null conditional to use mergedOptions.visualBurnRate wherever ctx.values.visualBurnRate is referenced (look for the renderEmojiStatus and renderTextStatus calculations inside the anonymous function in the burnRateInfo construction around calculateBurnRate/activeBlock), so config-file overrides are respected; keep the rest of the logic (costPerHour, BurnStatus, burnStatusMappings, output assembly) unchanged.
🧹 Nitpick comments (1)
apps/ccusage/src/commands/setup-statusline.ts (1)
249-251: Writesettings.jsonatomically.
writeFileSync()truncates the live file in place. If the process is interrupted mid-write, the user's Claude Code config can be left partially written. A temp-file + rename flow would make this much safer.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/ccusage/src/commands/setup-statusline.ts` around lines 249 - 251, The current code uses writeFileSync(settingsPath, ...) which can leave settings.json partially written if interrupted; change the flow in the setup code around mkdirSync and writeFileSync to perform an atomic write: write the settingsJson to a temporary file in the same directory (e.g., settingsPath + ".tmp" or a uniquely named tmp file), fsync/flush it, then rename/replace it to settingsPath (using fs.renameSync or equivalent) so the move is atomic; reference the existing symbols mkdirSync, settingsPath, settingsJson, and replace the writeFileSync call with this temp-file + rename sequence to ensure atomicity.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/ccusage/src/commands/setup-statusline.ts`:
- Around line 186-209: The code currently swallows a malformed settings.json by
logging the error and letting existingSettings be null, then doing a bare
return; change this so the command fails non‑zero: after the Result.pipe(...)
that produces existingSettings, detect the null result and call process.exit(1)
(or throw a descriptive Error) instead of returning. Update the branch that
checks existingSettings == null (and/or the Result.unwrap(null) fallback) so
that when JSON parsing failed (Result.try / Result.inspectError path) the
process terminates with a non‑zero exit code (referencing existingSettings,
settingsPath, Result.pipe, Result.try, Result.inspectError, Result.unwrap).
In `@apps/ccusage/src/commands/statusline.ts`:
- Around line 559-562: The costParts string always appends `
${pc.dim(blockTimeStr)}` even when `blockTimeStr` is empty, producing an extra
trailing space; update the construction of `costParts` (used to build
`costSegment`) to only append the `pc.dim(blockTimeStr)` fragment when
`blockTimeStr` is non-empty (e.g., conditionally include that piece or build an
array of parts and join), ensuring `dot`, `sessionDisplay`, `todayCost`,
`blockCostStr`, and the `blockTimeStr` fragment are combined without leaving an
extra space when `blockTimeStr` === ''.
---
Outside diff comments:
In `@apps/ccusage/src/commands/statusline.ts`:
- Around line 421-470: The branch that builds burnRateInfo is reading the
CLI-only flag ctx.values.visualBurnRate instead of the effective setting; update
the closure inside the burnRate != null conditional to use
mergedOptions.visualBurnRate wherever ctx.values.visualBurnRate is referenced
(look for the renderEmojiStatus and renderTextStatus calculations inside the
anonymous function in the burnRateInfo construction around
calculateBurnRate/activeBlock), so config-file overrides are respected; keep the
rest of the logic (costPerHour, BurnStatus, burnStatusMappings, output assembly)
unchanged.
---
Nitpick comments:
In `@apps/ccusage/src/commands/setup-statusline.ts`:
- Around line 249-251: The current code uses writeFileSync(settingsPath, ...)
which can leave settings.json partially written if interrupted; change the flow
in the setup code around mkdirSync and writeFileSync to perform an atomic write:
write the settingsJson to a temporary file in the same directory (e.g.,
settingsPath + ".tmp" or a uniquely named tmp file), fsync/flush it, then
rename/replace it to settingsPath (using fs.renameSync or equivalent) so the
move is atomic; reference the existing symbols mkdirSync, settingsPath,
settingsJson, and replace the writeFileSync call with this temp-file + rename
sequence to ensure atomicity.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: e20faa5c-099c-40cc-8e44-126d5b5d4f16
📒 Files selected for processing (2)
apps/ccusage/src/commands/setup-statusline.tsapps/ccusage/src/commands/statusline.ts
| const existingSettings: Record<string, unknown> | null = existsSync(settingsPath) | ||
| ? Result.pipe( | ||
| Result.try({ | ||
| try: () => { | ||
| const parsed: unknown = JSON.parse(readFileSync(settingsPath, 'utf-8')); | ||
| if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { | ||
| throw new Error('settings.json must contain a JSON object at the root'); | ||
| } | ||
| return parsed as Record<string, unknown>; | ||
| }, | ||
| catch: (error) => error, | ||
| })(), | ||
| Result.inspectError((error) => { | ||
| log(`\n${pc.red('✗')} Malformed settings.json at ${settingsPath}`); | ||
| log(` ${error instanceof Error ? error.message : String(error)}`); | ||
| log(` Please fix or remove the file and try again.`); | ||
| }), | ||
| Result.unwrap(null), | ||
| ) | ||
| : {}; | ||
|
|
||
| if (existingSettings == null) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
Fail the command when settings.json is malformed.
This path logs the parse error and then returns normally, so setup-statusline still exits successfully even though nothing was configured. Please surface a non-zero failure here instead of a bare return.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/commands/setup-statusline.ts` around lines 186 - 209, The
code currently swallows a malformed settings.json by logging the error and
letting existingSettings be null, then doing a bare return; change this so the
command fails non‑zero: after the Result.pipe(...) that produces
existingSettings, detect the null result and call process.exit(1) (or throw a
descriptive Error) instead of returning. Update the branch that checks
existingSettings == null (and/or the Result.unwrap(null) fallback) so that when
JSON parsing failed (Result.try / Result.inspectError path) the process
terminates with a non‑zero exit code (referencing existingSettings,
settingsPath, Result.pipe, Result.try, Result.inspectError, Result.unwrap).
| const dot = pc.dim(' · '); | ||
| const costParts = `${sessionDisplay} ${pc.dim('session')}${dot}${pc.cyan(formatCurrency(todayCost))} ${pc.dim('today')}${dot}${pc.cyan(blockCostStr)} ${pc.dim('block')} ${pc.dim(blockTimeStr)}`; | ||
| const costSegment = | ||
| burnRateInfo !== '' ? `${costParts} ${pc.dim('|')}${burnRateInfo}` : costParts; |
There was a problem hiding this comment.
Skip the block-time suffix when it is empty.
On the no-active-block path blockTimeStr is '', but this template always appends ${pc.dim(blockTimeStr)}. That leaves a trailing space in costSegment, which shows up as double spacing before the next separator.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/commands/statusline.ts` around lines 559 - 562, The
costParts string always appends ` ${pc.dim(blockTimeStr)}` even when
`blockTimeStr` is empty, producing an extra trailing space; update the
construction of `costParts` (used to build `costSegment`) to only append the
`pc.dim(blockTimeStr)` fragment when `blockTimeStr` is non-empty (e.g.,
conditionally include that piece or build an array of parts and join), ensuring
`dot`, `sessionDisplay`, `todayCost`, `blockCostStr`, and the `blockTimeStr`
fragment are combined without leaving an extra space when `blockTimeStr` === ''.
install.ps1 (Windows) and install.sh (Mac/Linux) clone the fork, build from source, and configure ~/.claude/settings.json automatically. Windows: irm https://raw.githubusercontent.com/r1di/ccusage/feat/statusline-improvements/install.ps1 | iex Mac/Linux: curl -fsSL https://raw.githubusercontent.com/r1di/ccusage/feat/statusline-improvements/install.sh | bash
Off-peak (2x active): bold green "⚡2x ON · 11d left" Peak (2x inactive): dim "⚡2x in 2h15m · 11d left" Green = you are currently getting 2x usage. Dim = 2x is not active yet, countdown to when it starts. Fix lint-staged to use npx eslint (works in git hook shell without pnpm in PATH).
There was a problem hiding this comment.
Actionable comments posted: 6
🧹 Nitpick comments (2)
install.sh (1)
47-47: Align installer-generatedstatusLinewith CLI setup behavior.Line [47] currently misses the
paddingproperty used bysetup-statusline, producing inconsistent statusline config across setup methods.♻️ Suggested parity update
-s.statusLine = { type: 'command', command: `node "${dist}" statusline` } +s.statusLine = { type: "command", command: `node "${dist}" statusline`, padding: 0 }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@install.sh` at line 47, The installer currently sets s.statusLine = { type: 'command', command: `node "${dist}" statusline` } without the padding property, causing mismatch with the CLI setup; update the installer to include the same padding property used by setup-statusline by adding padding (e.g., padding: <appropriate value>) to the s.statusLine object so the generated statusLine matches the CLI behavior and uses the same configuration format as setup-statusline.install.ps1 (1)
42-45: Keep installerstatusLineshape aligned withsetup-statusline.Line [42]-[45] omits
padding: 0, so users get different config depending on setup path.♻️ Minimal parity fix
$s | Add-Member -MemberType NoteProperty -Name "statusLine" -Value ([PSCustomObject]@{ type = "command" command = "node `"$dist`" statusline" + padding = 0 }) -Force🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@install.ps1` around lines 42 - 45, The statusLine PSCustomObject created via $s | Add-Member -MemberType NoteProperty -Name "statusLine" is missing the padding property, causing a shape mismatch with the setup-statusline config; update the object literal passed to Add-Member (the [PSCustomObject] used for "statusLine") to include padding = 0 so the installer branch produces the same shape as setup-statusline.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/ccusage/src/_promotions.ts`:
- Around line 114-120: The function getDaysUntilPromotionEnd claims to be
"inclusive of end date" but returns 0 when now equals promotion.endDate; fix by
making the calculation inclusive: compute the day-difference as currently done
and add 1 so the end date yields 1 (e.g. replace the returned expression with
Math.max(0, Math.ceil((endMs - todayMs) / (1000 * 60 * 60 * 24)) + 1)); update
any tests referencing the old behavior if needed.
- Around line 180-187: The public export list currently exposes
getActivePromotion, getDaysUntilPromotionEnd, getMinutesToOffPeak, and
isOffPeakHours even though only getEnhancedPromotionSegment and
getPromotionStatuslineSegment are used elsewhere; remove the four internal
helpers from the export block so only getEnhancedPromotionSegment and
getPromotionStatuslineSegment remain exported. Locate the export statement that
lists getActivePromotion, getDaysUntilPromotionEnd, getEnhancedPromotionSegment,
getMinutesToOffPeak, getPromotionStatuslineSegment, isOffPeakHours and delete
the four unused names (getActivePromotion, getDaysUntilPromotionEnd,
getMinutesToOffPeak, isOffPeakHours), leaving the two intended exports intact,
and run a project-wide search to confirm no other modules rely on those
now-unexported helpers.
In `@install.ps1`:
- Around line 39-41: The current catch for ConvertFrom-Json assigns $s =
[PSCustomObject]@{} which leads the later write-back to overwrite the user's
settings.json; instead, on JSON parse failure leave $s unset or set a sentinel
(e.g. $null or $settingsInvalid = $true), log the parse error with
$settings_path and do NOT perform the subsequent write operation that uses $s
(the code that writes back $settings_path must check the sentinel or $s before
writing). Update the catch block around Get-Content | ConvertFrom-Json to
preserve the original file on error and ensure any code that writes
$settings_path only runs when parsing succeeded (and $s contains valid data).
- Around line 15-34: The script runs native commands (npm install -g pnpm, git
-C $dir fetch/checkout/reset, git clone, pnpm install, pnpm run build) without
checking their exit codes, so after each native invocation check $LASTEXITCODE
and if non-zero call Write-Error with a descriptive message and exit with that
code (or a non-zero value) to stop the script; apply this pattern immediately
after the npm install -g pnpm call, after each git command (git -C $dir fetch
origin, git -C $dir checkout $branch, git -C $dir reset --hard "origin/$branch",
and git clone --branch $branch --depth 1 $repo $dir), and after pnpm install
--frozen-lockfile=false and pnpm run build to ensure failures abort the script
instead of proceeding.
In `@install.sh`:
- Around line 45-48: The current logic swallows JSON.parse errors into let s =
{} and then always writes back, which can erase a user's settings; change the
flow so you only call fs.writeFileSync(p, ...) if JSON.parse(fs.readFileSync(p,
'utf8')) succeeds: attempt to read and parse the file (using fs.readFileSync and
JSON.parse on p) and if parsing fails, do NOT overwrite p — instead log or print
an error and exit non-zero; if parsing succeeds, set s.statusLine = { type:
'command', command: `node "${dist}" statusline` } and then write the merged
object back with fs.writeFileSync. Ensure you reference the same variables (p,
dist, s, statusLine) so the code only writes when parse succeeded.
In `@package.json`:
- Around line 59-60: The pre-commit flow is incorrectly skipping subproject
ESLint because root lint-staged pattern "*.{js,ts,json,md}" sends staged files
to the root eslint.config.js which explicitly ignores apps/, packages/, docs/,
so nested eslint.config.js files never run; fix by either narrowing lint-staged
in package.json to only root files (e.g., change the pattern to target files in
the repository root only), or remove apps/, packages/, docs/ from the root
eslint.config.js ignores so root ESLint will lint staged subdirectory files, or
move the lint-staged configuration into each subdirectory package.json so each
subproject runs its own ESLint config; update package.json (lint-staged pattern)
and/or root eslint.config.js (ignores) or add per-package lint-staged entries
accordingly.
---
Nitpick comments:
In `@install.ps1`:
- Around line 42-45: The statusLine PSCustomObject created via $s | Add-Member
-MemberType NoteProperty -Name "statusLine" is missing the padding property,
causing a shape mismatch with the setup-statusline config; update the object
literal passed to Add-Member (the [PSCustomObject] used for "statusLine") to
include padding = 0 so the installer branch produces the same shape as
setup-statusline.
In `@install.sh`:
- Line 47: The installer currently sets s.statusLine = { type: 'command',
command: `node "${dist}" statusline` } without the padding property, causing
mismatch with the CLI setup; update the installer to include the same padding
property used by setup-statusline by adding padding (e.g., padding: <appropriate
value>) to the s.statusLine object so the generated statusLine matches the CLI
behavior and uses the same configuration format as setup-statusline.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: d48daa78-b999-46a1-aada-fa8e31d9bc18
📒 Files selected for processing (4)
apps/ccusage/src/_promotions.tsinstall.ps1install.shpackage.json
| * Calculates days remaining until promotion ends (inclusive of end date) | ||
| */ | ||
| function getDaysUntilPromotionEnd(promotion: Promotion, now: Date = new Date()): number { | ||
| const todayStr = getDateStringInTimezone(now, promotion.endTimezone); | ||
| const todayMs = new Date(`${todayStr}T00:00:00`).getTime(); | ||
| const endMs = new Date(`${promotion.endDate}T00:00:00`).getTime(); | ||
| return Math.max(0, Math.ceil((endMs - todayMs) / (1000 * 60 * 60 * 24))); |
There was a problem hiding this comment.
Docstring and behavior for day counting are inconsistent.
Line [114] says “inclusive of end date,” but current behavior returns 0 on the end date (also reflected by tests at Line [328]-[332]). Please align wording or logic.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/_promotions.ts` around lines 114 - 120, The function
getDaysUntilPromotionEnd claims to be "inclusive of end date" but returns 0 when
now equals promotion.endDate; fix by making the calculation inclusive: compute
the day-difference as currently done and add 1 so the end date yields 1 (e.g.
replace the returned expression with Math.max(0, Math.ceil((endMs - todayMs) /
(1000 * 60 * 60 * 24)) + 1)); update any tests referencing the old behavior if
needed.
| export { | ||
| getActivePromotion, | ||
| getDaysUntilPromotionEnd, | ||
| getEnhancedPromotionSegment, | ||
| getMinutesToOffPeak, | ||
| getPromotionStatuslineSegment, | ||
| isOffPeakHours, | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Import sites for _promotions ==="
rg -nP --type=ts 'import\s*\{[^}]*\}\s*from\s*["'\''][^"'\'']*_promotions(\.ts)?["'\'']' -C2Repository: ryoppippi/ccusage
Length of output: 695
Remove unexported internal helper functions from public API.
Lines 180–187 export six functions, but only getEnhancedPromotionSegment and getPromotionStatuslineSegment are imported by other modules (in statusline.ts). The remaining four—getActivePromotion, getDaysUntilPromotionEnd, getMinutesToOffPeak, isOffPeakHours—should not be exported, as per the guideline to only export symbols actually used by other modules.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/_promotions.ts` around lines 180 - 187, The public export
list currently exposes getActivePromotion, getDaysUntilPromotionEnd,
getMinutesToOffPeak, and isOffPeakHours even though only
getEnhancedPromotionSegment and getPromotionStatuslineSegment are used
elsewhere; remove the four internal helpers from the export block so only
getEnhancedPromotionSegment and getPromotionStatuslineSegment remain exported.
Locate the export statement that lists getActivePromotion,
getDaysUntilPromotionEnd, getEnhancedPromotionSegment, getMinutesToOffPeak,
getPromotionStatuslineSegment, isOffPeakHours and delete the four unused names
(getActivePromotion, getDaysUntilPromotionEnd, getMinutesToOffPeak,
isOffPeakHours), leaving the two intended exports intact, and run a project-wide
search to confirm no other modules rely on those now-unexported helpers.
| if (-not (Get-Command pnpm -ErrorAction SilentlyContinue)) { | ||
| Write-Host "Installing pnpm..." | ||
| npm install -g pnpm --loglevel=error | ||
| } | ||
|
|
||
| if (Test-Path "$dir\.git") { | ||
| Write-Host "Updating repo..." | ||
| git -C $dir fetch origin | ||
| git -C $dir checkout $branch | ||
| git -C $dir reset --hard "origin/$branch" | ||
| } else { | ||
| Write-Host "Cloning repo..." | ||
| git clone --branch $branch --depth 1 $repo $dir | ||
| } | ||
|
|
||
| Write-Host "Building..." | ||
| Set-Location $pkg | ||
| pnpm install --frozen-lockfile=false | ||
| pnpm run build | ||
|
|
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n '\$ErrorActionPreference|\$LASTEXITCODE|git -C|pnpm run build|npm install -g pnpm' install.ps1 -C2Repository: ryoppippi/ccusage
Length of output: 593
🏁 Script executed:
wc -l install.ps1Repository: ryoppippi/ccusage
Length of output: 75
🏁 Script executed:
cat -n install.ps1Repository: ryoppippi/ccusage
Length of output: 2093
🌐 Web query:
PowerShell $ErrorActionPreference native command exit code error handling
💡 Result:
In PowerShell, $ErrorActionPreference does not, by itself, turn a native (external) command’s non‑zero exit code into a terminating PowerShell error. It mainly controls how PowerShell handles non‑terminating errors produced by cmdlets / Write-Error. [1]
Default behavior (Windows PowerShell 5.1 + PowerShell 7.x)
- Native commands signal failure via exit codes, surfaced as
$LASTEXITCODE(not as a PowerShell error). [2] - Therefore,
try { ... } catch { ... }won’t catch a failing native command unless you convert the exit code to an error.
Example pattern:
git push
if ($LASTEXITCODE -ne 0) { throw "git failed with exit code $LASTEXITCODE" }($LASTEXITCODE is the conventional way to inspect native-command success/failure.) [2]
PowerShell 7.4+ (opt-in): make exit codes respect $ErrorActionPreference
PowerShell added $PSNativeCommandUseErrorActionPreference so that a non‑zero native exit code becomes a PowerShell error that then follows $ErrorActionPreference (for example, Stop makes it terminating). This is discussed as part of the 7.4 release. [3]
Typical usage:
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
robocopy ... # if it exits non-zero (per PowerShell’s mapping), you can catch/stop(Enable it only where you want this behavior; it changes how scripts react to native failures.) [3][1]
stderr vs “PowerShell errors” (common confusion)
Native commands can write to stderr without that being a PowerShell error. Also, starting in PowerShell 7.2, when you redirect native stderr (for example 2>&1), those redirected records don’t go into $Error and aren’t affected by $ErrorActionPreference. [1]
Sources
[1] Microsoft Learn — about_Preference_Variables (includes $ErrorActionPreference and native-command redirection notes)
[2] Microsoft Learn — about_Automatic_Variables (includes $LASTEXITCODE behavior)
[3] PowerShell Team blog — PowerShell 7.4 General Availability (native command error-action integration via $PSNativeCommandUseErrorActionPreference)
🌐 Web query:
PowerShell $LASTEXITCODE native command git npm pnpm
💡 Result:
In PowerShell, git / npm / pnpm are “native commands” (external executables / .cmd wrappers). For native commands, the authoritative way to check success/failure is:
$LASTEXITCODE= the process exit code from the most recent native command (0usually success; non‑zero failure as defined by that tool). (stackoverflow.com)$?= whether PowerShell thinks the last operation succeeded (works for cmdlets too). With native commands it can be affected by PowerShell’s error-stream handling and can disagree with$LASTEXITCODEin some cases. (codestudy.net)
Typical pattern (git/npm/pnpm)
& git status
$ec = $LASTEXITCODE
if ($ec -ne 0) { throw "git failed with exit code $ec" }
& npm test
$ec = $LASTEXITCODE
if ($ec -ne 0) { throw "npm failed with exit code $ec" }
& pnpm -v
$ec = $LASTEXITCODE
if ($ec -ne 0) { throw "pnpm failed with exit code $ec" }Important gotchas
-
$LASTEXITCODEis only for native commands
If you’ve only run PowerShell commands/cmdlets, it may be$null(or keep an older value), so read it immediately after the native command you care about. (gitlab.com) -
$?can be$falseeven when$LASTEXITCODEis0
If the native command writes to stderr, PowerShell may surface that as aNativeCommandError(especially with certain redirections), which can make$?false despite exit code 0. (codestudy.net)
If you tell me your exact snippet (especially if you pipe/redirection like 2>&1, *>, | Out-File, or you’re in CI), I can point out why you’re seeing a mismatch for git/npm/pnpm.
Citations:
- 1: https://stackoverflow.com/questions/71018199/get-npm-process-exitcode-as-output-in-powershell?utm_source=openai
- 2: https://www.codestudy.net/blog/lastexitcode-0-but-false-in-powershell-redirecting-stderr-to-stdout-gives-nativecommanderror/?utm_source=openai
- 3: https://gitlab.com/gitlab-org/gitlab-foss/-/issues/47989?utm_source=openai
- 4: https://www.codestudy.net/blog/lastexitcode-0-but-false-in-powershell-redirecting-stderr-to-stdout-gives-nativecommanderror/?utm_source=openai
Add error handling for native commands to prevent silent failures.
Lines 17, 22–24, and 33 execute native commands (npm, git, pnpm) without checking exit codes. If any of these fail, the script continues and writes a broken statusLine configuration, giving a false success message while leaving the statusline non-functional.
PowerShell's $ErrorActionPreference does not stop on native command failures; you must check $LASTEXITCODE explicitly. Apply the pattern below to all native command calls:
🔧 Proposed error handling pattern
+$ErrorActionPreference = "Stop"
+
+function Invoke-Checked {
+ param(
+ [scriptblock]$Command,
+ [string]$Action
+ )
+ & $Command
+ if ($LASTEXITCODE -ne 0) {
+ throw "$Action failed (exit code: $LASTEXITCODE)"
+ }
+}
+
if (-not (Get-Command pnpm -ErrorAction SilentlyContinue)) {
Write-Host "Installing pnpm..."
- npm install -g pnpm --loglevel=error
+ Invoke-Checked { npm install -g pnpm --loglevel=error } "Install pnpm"
}
if (Test-Path "$dir\.git") {
Write-Host "Updating repo..."
- git -C $dir fetch origin
- git -C $dir checkout $branch
- git -C $dir reset --hard "origin/$branch"
+ Invoke-Checked { git -C $dir fetch origin } "git fetch"
+ Invoke-Checked { git -C $dir checkout $branch } "git checkout"
+ Invoke-Checked { git -C $dir reset --hard "origin/$branch" } "git reset"
} else {
Write-Host "Cloning repo..."
- git clone --branch $branch --depth 1 $repo $dir
+ Invoke-Checked { git clone --branch $branch --depth 1 $repo $dir } "git clone"
}
Write-Host "Building..."
Set-Location $pkg
-pnpm install --frozen-lockfile=false
-pnpm run build
+Invoke-Checked { pnpm install --frozen-lockfile=false } "pnpm install"
+Invoke-Checked { pnpm run build } "pnpm run build"🧰 Tools
🪛 PSScriptAnalyzer (1.24.0)
[warning] 16-16: File 'install.ps1' uses Write-Host. Avoid using Write-Host because it might not work in all hosts, does not work when there is no host, and (prior to PS 5.0) cannot be suppressed, captured, or redirected. Instead, use Write-Output, Write-Verbose, or Write-Information.
(PSAvoidUsingWriteHost)
[warning] 21-21: File 'install.ps1' uses Write-Host. Avoid using Write-Host because it might not work in all hosts, does not work when there is no host, and (prior to PS 5.0) cannot be suppressed, captured, or redirected. Instead, use Write-Output, Write-Verbose, or Write-Information.
(PSAvoidUsingWriteHost)
[warning] 26-26: File 'install.ps1' uses Write-Host. Avoid using Write-Host because it might not work in all hosts, does not work when there is no host, and (prior to PS 5.0) cannot be suppressed, captured, or redirected. Instead, use Write-Output, Write-Verbose, or Write-Information.
(PSAvoidUsingWriteHost)
[warning] 30-30: File 'install.ps1' uses Write-Host. Avoid using Write-Host because it might not work in all hosts, does not work when there is no host, and (prior to PS 5.0) cannot be suppressed, captured, or redirected. Instead, use Write-Output, Write-Verbose, or Write-Information.
(PSAvoidUsingWriteHost)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@install.ps1` around lines 15 - 34, The script runs native commands (npm
install -g pnpm, git -C $dir fetch/checkout/reset, git clone, pnpm install, pnpm
run build) without checking their exit codes, so after each native invocation
check $LASTEXITCODE and if non-zero call Write-Error with a descriptive message
and exit with that code (or a non-zero value) to stop the script; apply this
pattern immediately after the npm install -g pnpm call, after each git command
(git -C $dir fetch origin, git -C $dir checkout $branch, git -C $dir reset
--hard "origin/$branch", and git clone --branch $branch --depth 1 $repo $dir),
and after pnpm install --frozen-lockfile=false and pnpm run build to ensure
failures abort the script instead of proceeding.
| try { $s = (Get-Content $settings_path -Raw -Encoding UTF8) | ConvertFrom-Json } | ||
| catch { $s = [PSCustomObject]@{} } | ||
| } else { $s = [PSCustomObject]@{} } |
There was a problem hiding this comment.
Avoid destructive fallback when settings.json is invalid.
Line [39]-[41] converts parse errors into {} and then Line [46] overwrites the file. That can wipe unrelated user settings.
🛡️ Proposed safer behavior
if (Test-Path $settings_path) {
try { $s = (Get-Content $settings_path -Raw -Encoding UTF8) | ConvertFrom-Json }
- catch { $s = [PSCustomObject]@{} }
+ catch {
+ Write-Error "Invalid JSON in $settings_path. Aborting to avoid overwriting user settings."
+ exit 1
+ }
} else { $s = [PSCustomObject]@{} }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| try { $s = (Get-Content $settings_path -Raw -Encoding UTF8) | ConvertFrom-Json } | |
| catch { $s = [PSCustomObject]@{} } | |
| } else { $s = [PSCustomObject]@{} } | |
| try { $s = (Get-Content $settings_path -Raw -Encoding UTF8) | ConvertFrom-Json } | |
| catch { | |
| Write-Error "Invalid JSON in $settings_path. Aborting to avoid overwriting user settings." | |
| exit 1 | |
| } | |
| } else { $s = [PSCustomObject]@{} } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@install.ps1` around lines 39 - 41, The current catch for ConvertFrom-Json
assigns $s = [PSCustomObject]@{} which leads the later write-back to overwrite
the user's settings.json; instead, on JSON parse failure leave $s unset or set a
sentinel (e.g. $null or $settingsInvalid = $true), log the parse error with
$settings_path and do NOT perform the subsequent write operation that uses $s
(the code that writes back $settings_path must check the sentinel or $s before
writing). Update the catch block around Get-Content | ConvertFrom-Json to
preserve the original file on error and ensure any code that writes
$settings_path only runs when parsing succeeded (and $s contains valid data).
| let s = {} | ||
| try { s = JSON.parse(fs.readFileSync(p, 'utf8')) } catch {} | ||
| s.statusLine = { type: 'command', command: `node "${dist}" statusline` } | ||
| fs.writeFileSync(p, JSON.stringify(s, null, 2)) |
There was a problem hiding this comment.
Do not overwrite user settings on JSON parse failure.
Line [46] swallows parse errors and Line [48] writes back anyway, which can erase existing settings if the file is malformed.
🛡️ Proposed safer parse/write behavior
let s = {}
-try { s = JSON.parse(fs.readFileSync(p, 'utf8')) } catch {}
+try {
+ s = JSON.parse(fs.readFileSync(p, "utf8"))
+} catch (error) {
+ process.stderr.write(`Invalid JSON in ${p}. Aborting to avoid overwriting user settings.\n`)
+ process.exit(1)
+}
s.statusLine = { type: 'command', command: `node "${dist}" statusline` }
fs.writeFileSync(p, JSON.stringify(s, null, 2))📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| let s = {} | |
| try { s = JSON.parse(fs.readFileSync(p, 'utf8')) } catch {} | |
| s.statusLine = { type: 'command', command: `node "${dist}" statusline` } | |
| fs.writeFileSync(p, JSON.stringify(s, null, 2)) | |
| let s = {} | |
| try { | |
| s = JSON.parse(fs.readFileSync(p, "utf8")) | |
| } catch (error) { | |
| process.stderr.write(`Invalid JSON in ${p}. Aborting to avoid overwriting user settings.\n`) | |
| process.exit(1) | |
| } | |
| s.statusLine = { type: 'command', command: `node "${dist}" statusline` } | |
| fs.writeFileSync(p, JSON.stringify(s, null, 2)) |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@install.sh` around lines 45 - 48, The current logic swallows JSON.parse
errors into let s = {} and then always writes back, which can erase a user's
settings; change the flow so you only call fs.writeFileSync(p, ...) if
JSON.parse(fs.readFileSync(p, 'utf8')) succeeds: attempt to read and parse the
file (using fs.readFileSync and JSON.parse on p) and if parsing fails, do NOT
overwrite p — instead log or print an error and exit non-zero; if parsing
succeeds, set s.statusLine = { type: 'command', command: `node "${dist}"
statusline` } and then write the merged object back with fs.writeFileSync.
Ensure you reference the same variables (p, dist, s, statusLine) so the code
only writes when parse succeeded.
| "*.{js,ts,json,md}": [ | ||
| "npx --no-install eslint --fix --cache" |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== lint-staged entry in package.json ==="
rg -n '"lint-staged"|"\*\.\{js,ts,json,md\}"|eslint --fix --cache' package.json -A4 -B2
echo
echo "=== root eslint config (ignore scope + type) ==="
sed -n '1,120p' eslint.config.jsRepository: ryoppippi/ccusage
Length of output: 641
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Monorepo structure (top-level directories) ==="
ls -la | grep "^d" | awk '{print $NF}' | grep -E '^(apps|packages|docs)$' || echo "None found at root"
echo
echo "=== Check for nested package.json files ==="
find . -maxdepth 3 -name "package.json" -type f | head -20
echo
echo "=== Check for nested ESLint configs ==="
find . -maxdepth 3 -name "eslint.config.*" -type f
echo
echo "=== Check for nested lint-staged configs in package.json ==="
rg -l '"lint-staged"' --type json | head -10Repository: ryoppippi/ccusage
Length of output: 841
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "=== Check one nested eslint.config.js structure ==="
head -15 apps/ccusage/eslint.config.js
echo
echo "=== Verify: staging a file in subdirectory would bypass linting ==="
echo "Example scenario: if 'apps/ccusage/src/main.ts' is staged,"
echo "lint-staged calls: npx --no-install eslint --fix --cache apps/ccusage/src/main.ts"
echo "But root eslint.config.js has ignores: ['apps', 'packages', 'docs']"
echo "Result: File is NOT linted (ignored by root ESLint)"
echo
echo "Root ESLint ignore list:"
rg "ignores:" eslint.config.js -A1Repository: ryoppippi/ccusage
Length of output: 793
Staged files in subdirectories bypass root ESLint linting due to mismatched ignore scope.
The root lint-staged pattern *.{js,ts,json,md} matches files in apps/, packages/, and docs/, but the root eslint.config.js explicitly ignores those same paths. When a file in a subdirectory is staged, lint-staged invokes root ESLint on it, ESLint silently ignores it per the ignores list, and the pre-commit check incorrectly passes without linting. The nested eslint.config.js files configured in each subdirectory are never invoked.
Fix by either: removing those paths from the root ignore list, scoping root lint-staged to root-only files, or moving lint-staged configuration into nested package.json files to use their respective eslint configs.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@package.json` around lines 59 - 60, The pre-commit flow is incorrectly
skipping subproject ESLint because root lint-staged pattern "*.{js,ts,json,md}"
sends staged files to the root eslint.config.js which explicitly ignores apps/,
packages/, docs/, so nested eslint.config.js files never run; fix by either
narrowing lint-staged in package.json to only root files (e.g., change the
pattern to target files in the repository root only), or remove apps/,
packages/, docs/ from the root eslint.config.js ignores so root ESLint will lint
staged subdirectory files, or move the lint-staged configuration into each
subdirectory package.json so each subproject runs its own ESLint config; update
package.json (lint-staged pattern) and/or root eslint.config.js (ignores) or add
per-package lint-staged entries accordingly.
Off-peak display now: "⚡2x ON 9h0m · 11d left" - Countdown shows how long 2x is still active - Calculates minutes until next peak window starts - Works correctly before midnight (e.g. 2 AM → 3h) and after noon (e.g. 3 PM → 14h)
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (2)
apps/ccusage/src/_promotions.ts (2)
210-218: 🛠️ Refactor suggestion | 🟠 MajorTrim public exports to externally used symbols only.
This export surface still appears broader than needed for module consumers. Keep helper functions internal unless they are imported by other modules.
As per coding guidelines, "Only export symbols that are actually used by other modules."#!/bin/bash set -euo pipefail # Verify which symbols from _promotions are actually imported elsewhere. rg -nP --type=ts -C2 'import\s*\{[^}]*\}\s*from\s*["'"'"'][^"'"'"']*_promotions(\.ts)?["'"'"']'🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/ccusage/src/_promotions.ts` around lines 210 - 218, The module currently publicly exports getActivePromotion, getDaysUntilPromotionEnd, getEnhancedPromotionSegment, getMinutesToOffPeak, getMinutesToPeak, getPromotionStatuslineSegment, and isOffPeakHours; trim this list to only the symbols actually imported by other modules by running the suggested ripgrep check and removing any helper functions that are not referenced externally. Locate the export block that names these functions and remove unused symbols (e.g., drop helpers like getMinutesToOffPeak/getMinutesToPeak/getEnhancedPromotionSegment if not found by the search), leaving only the externally consumed exports (for example getActivePromotion or getPromotionStatuslineSegment if they are imported elsewhere), and ensure any local callers still work (convert removed exports to plain internal functions). Also update any re-exports or barrel files that forward symbols from this module to avoid breaking imports.
139-145:⚠️ Potential issue | 🟡 MinorDocstring and behavior disagree on end-date counting.
Line 139 says “inclusive of end date,” but Line 145 returns
0on the end date (also asserted at Lines 359-363). Please align either the wording or the calculation.Also applies to: 359-363
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/ccusage/src/_promotions.ts` around lines 139 - 145, The docstring for getDaysUntilPromotionEnd claims the end date is counted inclusive, but the current calculation returns 0 on the end date; change the calculation in getDaysUntilPromotionEnd to add 1 to the day difference (e.g., Math.max(0, Math.ceil((endMs - todayMs) / (1000*60*60*24)) + 1)) so the end date yields 1 and prior days count correctly, and update any affected assertions/tests around the referenced assertions (lines 359-363) if they expect the previous behavior; keep the function name getDaysUntilPromotionEnd and the timezone-based date construction unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/ccusage/src/_promotions.ts`:
- Around line 141-145: getDaysUntilPromotionEnd currently builds Date objects
from date-only strings which are parsed as local time and can yield wrong day
differences across timezones/DST; instead parse the YYYY-MM-DD returned by
getDateStringInTimezone(now, promotion.endTimezone) and promotion.endDate into
year/month/day integers and compute UTC midnights via Date.UTC(year, month-1,
day) for both today and end, then compute the day difference as Math.max(0,
Math.ceil((endUtcMs - todayUtcMs) / (1000*60*60*24))); update the logic in
getDaysUntilPromotionEnd to use these Date.UTC-based timestamps so calculations
are timezone-stable.
---
Duplicate comments:
In `@apps/ccusage/src/_promotions.ts`:
- Around line 210-218: The module currently publicly exports getActivePromotion,
getDaysUntilPromotionEnd, getEnhancedPromotionSegment, getMinutesToOffPeak,
getMinutesToPeak, getPromotionStatuslineSegment, and isOffPeakHours; trim this
list to only the symbols actually imported by other modules by running the
suggested ripgrep check and removing any helper functions that are not
referenced externally. Locate the export block that names these functions and
remove unused symbols (e.g., drop helpers like
getMinutesToOffPeak/getMinutesToPeak/getEnhancedPromotionSegment if not found by
the search), leaving only the externally consumed exports (for example
getActivePromotion or getPromotionStatuslineSegment if they are imported
elsewhere), and ensure any local callers still work (convert removed exports to
plain internal functions). Also update any re-exports or barrel files that
forward symbols from this module to avoid breaking imports.
- Around line 139-145: The docstring for getDaysUntilPromotionEnd claims the end
date is counted inclusive, but the current calculation returns 0 on the end
date; change the calculation in getDaysUntilPromotionEnd to add 1 to the day
difference (e.g., Math.max(0, Math.ceil((endMs - todayMs) / (1000*60*60*24)) +
1)) so the end date yields 1 and prior days count correctly, and update any
affected assertions/tests around the referenced assertions (lines 359-363) if
they expect the previous behavior; keep the function name
getDaysUntilPromotionEnd and the timezone-based date construction unchanged.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: fe83d3ad-aa74-4105-b505-2857dd82cad1
📒 Files selected for processing (1)
apps/ccusage/src/_promotions.ts
| function getDaysUntilPromotionEnd(promotion: Promotion, now: Date = new Date()): number { | ||
| const todayStr = getDateStringInTimezone(now, promotion.endTimezone); | ||
| const todayMs = new Date(`${todayStr}T00:00:00`).getTime(); | ||
| const endMs = new Date(`${promotion.endDate}T00:00:00`).getTime(); | ||
| return Math.max(0, Math.ceil((endMs - todayMs) / (1000 * 60 * 60 * 24))); |
There was a problem hiding this comment.
🧩 Analysis chain
🌐 Web query:
In JavaScript (Node.js), does new Date("2026-03-13T00:00:00")use local time, and can DST transitions affect day-difference calculations likeMath.ceil((endMs - startMs)/86400000)?
💡 Result:
Yes.
new Date("2026-03-13T00:00:00")is an ISO date-time form without a time-zone offset, so it’s interpreted in the local time zone (in Node.js: whatever the process time zone is—often the host zone, or whatTZsets). MDN notes: when the offset is absent, date-time forms are local time (while date-only forms like"2026-03-13"are treated as UTC). [1]- DST can absolutely affect calculations like
Math.ceil((endMs - startMs) / 86400000)when yourDates represent local times: some local “days” are 23 or 25 hours, so the millisecond difference between two local midnights across a DST change may not be a multiple of86_400_000. The date-fns maintainers explicitly call out that a “day” is not always 24 hours because of DST. [2]
If you want “calendar day” differences, avoid dividing milliseconds by 86,400,000 in local time; compute using UTC midnights (or use Temporal.PlainDate / a calendar-day diff helper) instead. [1][2]
Sources: [1] MDN Date reference; [2] date-fns issue discussion on DST and day length.
🏁 Script executed:
cat -n apps/ccusage/src/_promotions.ts | sed -n '135,155p'Repository: ryoppippi/ccusage
Length of output: 986
🏁 Script executed:
rg -n "getDateStringInTimezone" apps/ccusage/src/ -A 3 -B 1Repository: ryoppippi/ccusage
Length of output: 1314
🏁 Script executed:
rg -n "type Promotion\|interface Promotion" apps/ccusage/src/ -A 5Repository: ryoppippi/ccusage
Length of output: 43
🏁 Script executed:
rg -n "getDaysUntilPromotionEnd" apps/ccusage/src/ -B 2 -A 2Repository: ryoppippi/ccusage
Length of output: 2430
🏁 Script executed:
cat -n apps/ccusage/src/_promotions.ts | sed -n '52,60p'Repository: ryoppippi/ccusage
Length of output: 368
🏁 Script executed:
cat -n apps/ccusage/src/_promotions.ts | sed -n '350,369p'Repository: ryoppippi/ccusage
Length of output: 849
🏁 Script executed:
rg -n "type Promotion|interface Promotion" apps/ccusage/src/ -A 10Repository: ryoppippi/ccusage
Length of output: 627
🏁 Script executed:
cat -n apps/ccusage/src/_promotions.ts | sed -n '1,50p' | grep -A 20 "interface\|type"Repository: ryoppippi/ccusage
Length of output: 578
🏁 Script executed:
cat -n apps/ccusage/src/_promotions.ts | sed -n '24,35p'Repository: ryoppippi/ccusage
Length of output: 484
🏁 Script executed:
cat -n apps/ccusage/src/_promotions.ts | sed -n '64,72p'Repository: ryoppippi/ccusage
Length of output: 392
Use timezone-stable day math for date-only comparisons.
Line 143–144 parses date-only strings as local time, which produces incorrect day differences when the process timezone differs from promotion.endTimezone, or when dates span DST transitions (e.g., March 13, 2026 in America/Los_Angeles). Millisecond arithmetic across local day boundaries is unreliable; use UTC midnight timestamps instead.
🔧 Proposed fix
function getDaysUntilPromotionEnd(promotion: Promotion, now: Date = new Date()): number {
const todayStr = getDateStringInTimezone(now, promotion.endTimezone);
- const todayMs = new Date(`${todayStr}T00:00:00`).getTime();
- const endMs = new Date(`${promotion.endDate}T00:00:00`).getTime();
+ const [todayY, todayM, todayD] = todayStr.split("-").map(Number);
+ const [endY, endM, endD] = promotion.endDate.split("-").map(Number);
+ const todayMs = Date.UTC(todayY, todayM - 1, todayD);
+ const endMs = Date.UTC(endY, endM - 1, endD);
return Math.max(0, Math.ceil((endMs - todayMs) / (1000 * 60 * 60 * 24)));
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/ccusage/src/_promotions.ts` around lines 141 - 145,
getDaysUntilPromotionEnd currently builds Date objects from date-only strings
which are parsed as local time and can yield wrong day differences across
timezones/DST; instead parse the YYYY-MM-DD returned by
getDateStringInTimezone(now, promotion.endTimezone) and promotion.endDate into
year/month/day integers and compute UTC midnights via Date.UTC(year, month-1,
day) for both today and end, then compute the day difference as Math.max(0,
Math.ceil((endUtcMs - todayUtcMs) / (1000*60*60*24))); update the logic in
getDaysUntilPromotionEnd to use these Date.UTC-based timestamps so calculations
are timezone-stable.
…/block In auto mode, session cost previously came from Claude Code's internal cost tracking (hookData.cost.total_cost_usd) while today and block costs used ccusage's own calculation via LiteLLM. This caused session > today when the pricing engines diverged. Now auto mode prefers ccusage calculation for session cost too, with CC's cost as fallback. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Summary
ses/day/blk) with full words (session/today/block)setup-statuslinecommand for auto-configuring Claude Codesettings.jsonmkdirSyncbeforewriteFileSyncto prevent ENOENT crash on fresh installsformatCompactTokensutility for compact token count display--promotion-display,--show-session-duration,--show-lines-changedoptionsTest plan
pnpm run formatpassespnpm typecheckpassespnpm run test274 passed (2 pre-existing failures: Windows path bug + 600MB timeout)setup-statusline --dry-runverifiedGenerated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Chores