ccburn 0.2.2__py3-none-any.whl → 0.3.1__py3-none-any.whl

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.
ccburn/app.py CHANGED
@@ -1,8 +1,8 @@
1
1
  """Main application class for ccburn."""
2
2
 
3
+ import os
3
4
  import signal
4
5
  import threading
5
- import time
6
6
  from datetime import datetime, timedelta, timezone
7
7
  from typing import Any
8
8
 
@@ -34,7 +34,7 @@ class CCBurnApp:
34
34
  self,
35
35
  limit_type: LimitType = LimitType.SESSION,
36
36
  interval: int = 5,
37
- since: datetime | None = None,
37
+ since_duration: timedelta | None = None,
38
38
  json_output: bool = False,
39
39
  once: bool = False,
40
40
  compact: bool = False,
@@ -45,7 +45,7 @@ class CCBurnApp:
45
45
  Args:
46
46
  limit_type: Which limit to display
47
47
  interval: Refresh interval in seconds
48
- since: Only show data since this time (zoom view)
48
+ since_duration: Time window duration for zoom view (sliding window)
49
49
  json_output: Output JSON instead of TUI
50
50
  once: Print once and exit
51
51
  compact: Single-line output for status bars
@@ -53,24 +53,40 @@ class CCBurnApp:
53
53
  """
54
54
  self.limit_type = limit_type
55
55
  self.interval = interval
56
- self.since = since
56
+ self.since_duration = since_duration
57
57
  self.json_output = json_output
58
58
  self.once = once
59
59
  self.compact = compact
60
60
  self.debug = debug
61
61
 
62
- self.console = Console()
62
+ # Disable legacy_windows mode for modern terminals to prevent Unicode issues
63
+ # Rich may incorrectly detect legacy mode even in Windows Terminal
64
+ use_legacy = None # Auto-detect by default
65
+ if os.environ.get("WT_SESSION"): # Windows Terminal
66
+ use_legacy = False
67
+ self.console = Console(legacy_windows=use_legacy)
63
68
  self.client = UsageClient()
64
69
  self.history: HistoryDB | None = None
65
70
  self.layout = BurnupLayout(self.console)
66
71
 
67
72
  # State
68
- self.running = threading.Event()
73
+ self.running = True
74
+ self._stop_event = threading.Event() # For interruptible sleep
69
75
  self.last_snapshot: UsageSnapshot | None = None
70
76
  self.last_fetch_time: datetime | None = None
71
77
  self.last_error: str | None = None
72
78
  self.snapshots: list[UsageSnapshot] = []
73
79
 
80
+ def _get_since_datetime(self) -> datetime | None:
81
+ """Calculate the since datetime based on current time and duration.
82
+
83
+ Returns:
84
+ datetime for filtering, or None if no duration set
85
+ """
86
+ if self.since_duration is None:
87
+ return None
88
+ return datetime.now(timezone.utc) - self.since_duration
89
+
74
90
  def _setup_signal_handlers(self) -> None:
75
91
  """Setup signal handlers for graceful shutdown."""
76
92
 
@@ -93,10 +109,10 @@ class CCBurnApp:
93
109
  self.history = HistoryDB()
94
110
  # Prune old data on startup
95
111
  self.history.prune_old_data()
96
- # Load existing snapshots
112
+ # Load existing snapshots (use current since datetime)
97
113
  self.snapshots = self.history.get_snapshots_for_limit(
98
114
  self.limit_type,
99
- since=self.since,
115
+ since=self._get_since_datetime(),
100
116
  )
101
117
  except Exception as e:
102
118
  # Fall back to in-memory if SQLite fails
@@ -134,10 +150,10 @@ class CCBurnApp:
134
150
  self.last_snapshot = snapshot
135
151
  self.last_fetch_time = snapshot.timestamp
136
152
  self.last_error = None
137
- # Reload snapshots from database
153
+ # Reload snapshots from database (use current since datetime)
138
154
  self.snapshots = self.history.get_snapshots_for_limit(
139
155
  self.limit_type,
140
- since=self.since,
156
+ since=self._get_since_datetime(),
141
157
  )
142
158
  return True
143
159
 
@@ -154,9 +170,10 @@ class CCBurnApp:
154
170
  # Add to local list
155
171
  self.snapshots.append(snapshot)
156
172
 
157
- # Keep only relevant snapshots (based on since or window)
158
- if self.since:
159
- self.snapshots = [s for s in self.snapshots if s.timestamp >= self.since]
173
+ # Keep only relevant snapshots (based on since_duration or window)
174
+ since_dt = self._get_since_datetime()
175
+ if since_dt:
176
+ self.snapshots = [s for s in self.snapshots if s.timestamp >= since_dt]
160
177
  else:
161
178
  # Keep last 24 hours of data for calculations
162
179
  cutoff = datetime.now(timezone.utc) - timedelta(hours=24)
@@ -180,18 +197,6 @@ class CCBurnApp:
180
197
  self.last_error = f"Unexpected error: {e}"
181
198
  return False
182
199
 
183
- def _should_refresh(self) -> bool:
184
- """Check if we should fetch new data.
185
-
186
- Returns:
187
- True if it's time to refresh
188
- """
189
- if self.last_fetch_time is None:
190
- return True
191
-
192
- elapsed = (datetime.now(timezone.utc) - self.last_fetch_time).total_seconds()
193
- return elapsed >= self.interval
194
-
195
200
  def run(self) -> int:
196
201
  """Run the application.
197
202
 
@@ -276,7 +281,7 @@ class CCBurnApp:
276
281
  limit_data,
277
282
  self.snapshots,
278
283
  error=self.last_error,
279
- since=self.since,
284
+ since_duration=self.since_duration,
280
285
  )
281
286
  self.console.print(layout)
282
287
 
@@ -294,7 +299,7 @@ class CCBurnApp:
294
299
  Exit code
295
300
  """
296
301
  self._setup_signal_handlers()
297
- self.running.set()
302
+ self.running = True
298
303
 
299
304
  try:
300
305
  # Create initial display
@@ -307,7 +312,7 @@ class CCBurnApp:
307
312
  limit_data,
308
313
  self.snapshots,
309
314
  error=self.last_error,
310
- since=self.since,
315
+ since_duration=self.since_duration,
311
316
  )
312
317
 
313
318
  # Set initial window title
@@ -316,7 +321,7 @@ class CCBurnApp:
316
321
  with Live(
317
322
  initial_layout,
318
323
  console=self.console,
319
- refresh_per_second=1,
324
+ auto_refresh=False, # Manual refresh only - saves CPU
320
325
  transient=False,
321
326
  screen=True,
322
327
  vertical_overflow="visible",
@@ -341,18 +346,14 @@ class CCBurnApp:
341
346
  Args:
342
347
  live: Rich Live instance
343
348
  """
344
- last_update = 0.0
345
-
346
- while self.running.is_set():
349
+ while self.running:
347
350
  try:
348
- current_time = time.time()
349
-
350
- # Check if we should refresh data
351
- if self._should_refresh():
352
- self._fetch_and_update()
351
+ # Fetch new data
352
+ data_changed = self._fetch_and_update()
353
353
 
354
- # Update display
355
- if current_time - last_update >= 1.0: # Update display every second
354
+ # Only re-render layout when data actually changes
355
+ # This avoids expensive chart re-rendering
356
+ if data_changed:
356
357
  limit_data = None
357
358
  if self.last_snapshot:
358
359
  limit_data = self.last_snapshot.get_limit(self.limit_type)
@@ -367,18 +368,19 @@ class CCBurnApp:
367
368
  self.snapshots,
368
369
  error=self.last_error,
369
370
  stale_since=stale_since,
370
- since=self.since,
371
+ since_duration=self.since_duration,
371
372
  )
372
373
  live.update(updated_layout)
374
+ live.refresh() # Manual refresh since auto_refresh=False
373
375
  self._update_window_title()
374
- last_update = current_time
375
376
 
376
- # Small sleep to prevent busy waiting
377
- time.sleep(0.05)
377
+ # Sleep until next refresh interval
378
+ # Use Event.wait() for interruptible sleep on shutdown
379
+ self._stop_event.wait(timeout=self.interval)
378
380
 
379
381
  except Exception:
380
382
  # Log but continue
381
- time.sleep(0.5)
383
+ self._stop_event.wait(timeout=self.interval)
382
384
 
383
385
  def _create_json_output(self) -> dict:
384
386
  """Create JSON output structure.
@@ -416,7 +418,7 @@ class CCBurnApp:
416
418
  "status": metrics.status,
417
419
  }
418
420
 
419
- # Add burn rate for selected limit
421
+ # Add burn rate and projection for selected limit
420
422
  limit_data = self.last_snapshot.get_limit(self.limit_type)
421
423
  if limit_data:
422
424
  metrics = calculate_burn_metrics(limit_data, self.snapshots)
@@ -428,11 +430,45 @@ class CCBurnApp:
428
430
  }
429
431
  output["recommendation"] = metrics.recommendation
430
432
 
433
+ # Add projection data
434
+ if metrics.percent_per_hour > 0:
435
+ current_pct = limit_data.utilization * 100
436
+ remaining_pct = 100.0 - current_pct
437
+ hours_to_100 = remaining_pct / metrics.percent_per_hour
438
+
439
+ now = datetime.now(timezone.utc)
440
+ remaining_window_hours = (limit_data.resets_at - now).total_seconds() / 3600
441
+
442
+ if hours_to_100 <= remaining_window_hours:
443
+ # Will hit 100% before window ends
444
+ projected_end_pct = 100.0
445
+ hits_100 = True
446
+ status = "warning"
447
+ else:
448
+ # Won't hit 100%
449
+ projected_end_pct = current_pct + (metrics.percent_per_hour * remaining_window_hours)
450
+ hits_100 = False
451
+ status = "safe"
452
+
453
+ output["projection"] = {
454
+ "available": True,
455
+ "projected_end_pct": round(min(projected_end_pct, 100.0), 1),
456
+ "hits_100": hits_100,
457
+ "hours_to_100": round(hours_to_100, 1) if hits_100 else None,
458
+ "status": status,
459
+ }
460
+ else:
461
+ output["projection"] = {
462
+ "available": False,
463
+ "reason": "insufficient_data" if metrics.percent_per_hour == 0 else "usage_decreasing",
464
+ }
465
+
431
466
  return output
432
467
 
433
468
  def stop(self) -> None:
434
469
  """Stop the application."""
435
- self.running.clear()
470
+ self.running = False
471
+ self._stop_event.set() # Wake up from sleep immediately
436
472
 
437
473
  def _update_window_title(self) -> None:
438
474
  """Update terminal window title with current status."""
ccburn/cli.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """CLI command definitions for ccburn."""
2
2
 
3
3
  import re
4
- from datetime import datetime, timedelta, timezone
4
+ from datetime import timedelta
5
5
 
6
6
  import typer
7
7
  from rich.console import Console
@@ -132,12 +132,11 @@ def run_app(
132
132
  except ImportError:
133
133
  from ccburn.app import CCBurnApp
134
134
 
135
- # Calculate since datetime if provided
136
- since_dt = None
135
+ # Parse since duration if provided
136
+ since_duration = None
137
137
  if since:
138
138
  try:
139
- since_delta = parse_duration(since)
140
- since_dt = datetime.now(timezone.utc) - since_delta
139
+ since_duration = parse_duration(since)
141
140
  except typer.BadParameter as e:
142
141
  typer.echo(f"Error: {e}", err=True)
143
142
  raise typer.Exit(1) from None
@@ -145,7 +144,7 @@ def run_app(
145
144
  app = CCBurnApp(
146
145
  limit_type=limit_type,
147
146
  interval=interval,
148
- since=since_dt,
147
+ since_duration=since_duration,
149
148
  json_output=json_output,
150
149
  once=once,
151
150
  compact=compact,
ccburn/display/chart.py CHANGED
@@ -9,10 +9,10 @@ from rich.console import Console, ConsoleOptions, Group, RenderableType
9
9
  from rich.jupyter import JupyterMixin
10
10
 
11
11
  try:
12
- from ..data.models import LimitData, UsageSnapshot
12
+ from ..data.models import BurnMetrics, LimitData, UsageSnapshot
13
13
  from ..utils.formatting import get_utilization_color
14
14
  except ImportError:
15
- from ccburn.data.models import LimitData, UsageSnapshot
15
+ from ccburn.data.models import BurnMetrics, LimitData, UsageSnapshot
16
16
  from ccburn.utils.formatting import get_utilization_color
17
17
 
18
18
 
@@ -23,21 +23,24 @@ class BurnupChart(JupyterMixin):
23
23
  self,
24
24
  limit_data: LimitData | None,
25
25
  snapshots: list[UsageSnapshot],
26
- since: datetime | None = None,
26
+ since_duration: timedelta | None = None,
27
27
  explicit_height: int | None = None,
28
+ burn_metrics: BurnMetrics | None = None,
28
29
  ):
29
30
  """Initialize the burnup chart.
30
31
 
31
32
  Args:
32
33
  limit_data: Current limit data (for window boundaries)
33
34
  snapshots: Historical snapshots to plot
34
- since: Only show data since this time (zoom view)
35
+ since_duration: Duration for zoom view (sliding window, e.g., last 1h)
35
36
  explicit_height: Override chart height
37
+ burn_metrics: Burn rate metrics for projection line
36
38
  """
37
39
  self.limit_data = limit_data
38
40
  self.snapshots = snapshots
39
- self.since = since
41
+ self.since_duration = since_duration
40
42
  self.explicit_height = explicit_height
43
+ self.burn_metrics = burn_metrics
41
44
  self.decoder = AnsiDecoder()
42
45
 
43
46
  def __rich_console__(
@@ -86,12 +89,14 @@ class BurnupChart(JupyterMixin):
86
89
  window_end = self.limit_data.resets_at
87
90
  now = datetime.now(timezone.utc)
88
91
 
89
- # Display window (may be zoomed with --since)
92
+ # Display window (may be zoomed with --since as a sliding window)
90
93
  display_start = original_window_start
91
94
  display_end = window_end
92
- if self.since:
93
- display_start = max(original_window_start, self.since)
94
- display_end = now # When zoomed, show until now, not session end
95
+ if self.since_duration:
96
+ # Sliding window: always show the last N duration (e.g., last 1h)
97
+ # Both start and end move forward with time
98
+ display_start = max(original_window_start, now - self.since_duration)
99
+ display_end = now
95
100
 
96
101
  # Filter snapshots to display window
97
102
  relevant_snapshots = [
@@ -160,11 +165,46 @@ class BurnupChart(JupyterMixin):
160
165
  label="Usage",
161
166
  )
162
167
 
168
+ # Plot projection line if we have positive burn rate (not on zoomed views)
169
+ hits_100_hours = None # Track when projection hits 100% for vertical marker
170
+ if self.burn_metrics and self.burn_metrics.percent_per_hour > 0 and not self.since_duration:
171
+ current_pct = self.limit_data.utilization * 100
172
+ now_hours = to_hours(now)
173
+
174
+ # Only draw projection if not already at or above 100%
175
+ if current_pct < 100.0:
176
+ # Calculate time to hit 100%
177
+ remaining_pct = 100.0 - current_pct
178
+ hours_to_100 = remaining_pct / self.burn_metrics.percent_per_hour
179
+
180
+ # End point: either 100% or window end, whichever comes first
181
+ remaining_window_hours = (display_end - now).total_seconds() / 3600
182
+
183
+ if hours_to_100 <= remaining_window_hours:
184
+ # Will hit 100% before window ends
185
+ end_hours = now_hours + hours_to_100
186
+ end_pct = 100.0
187
+ proj_color = (255, 100, 0) # Orange - warning
188
+ hits_100_hours = end_hours # Mark for vertical line
189
+ else:
190
+ # Won't hit 100%, project to window end
191
+ end_hours = now_hours + remaining_window_hours
192
+ end_pct = current_pct + (self.burn_metrics.percent_per_hour * remaining_window_hours)
193
+ proj_color = (100, 200, 100) # Green - safe
194
+
195
+ plt.plot(
196
+ [now_hours, end_hours],
197
+ [current_pct, min(end_pct, 100.0)],
198
+ color=proj_color,
199
+ marker="braille",
200
+ label="Projection",
201
+ )
202
+
163
203
  # Configure axes
164
204
  plt.xlim(0, display_hours)
165
205
 
166
206
  # Y-axis: dynamic when zoomed (--since), fixed 0-100 otherwise
167
- if self.since and values:
207
+ if self.since_duration and values:
168
208
  # Calculate dynamic range from data with padding
169
209
  all_y_values = values + pace_y
170
210
  data_min = min(all_y_values)
@@ -186,7 +226,7 @@ class BurnupChart(JupyterMixin):
186
226
  # Add "now" vertical line when showing full window (not zoomed)
187
227
  # Use dotted effect by plotting points at intervals
188
228
  now_hours_for_tick = None
189
- if not self.since:
229
+ if not self.since_duration:
190
230
  now_hours = to_hours(now)
191
231
  if 0 < now_hours < display_hours:
192
232
  # Create dotted vertical line with points using braille marker to match other lines
@@ -196,6 +236,15 @@ class BurnupChart(JupyterMixin):
196
236
  plt.plot(dot_x, dot_y, color=(0, 120, 255), marker="braille", label="Now")
197
237
  now_hours_for_tick = now_hours
198
238
 
239
+ # Add "hits 100%" vertical line when projection exceeds budget
240
+ hits_100_hours_for_tick = None
241
+ if hits_100_hours is not None and 0 < hits_100_hours < display_hours:
242
+ num_dots = 20
243
+ dot_y = [y_min + i * (y_max - y_min) / (num_dots - 1) for i in range(num_dots)]
244
+ dot_x = [hits_100_hours] * num_dots
245
+ plt.plot(dot_x, dot_y, color=(255, 100, 0), marker="braille", label="Depleted")
246
+ hits_100_hours_for_tick = hits_100_hours
247
+
199
248
  # Enable right Y axis with same range
200
249
  plt.plot([display_hours], [y_max], marker=" ", yside="right") # Hidden point to enable right axis
201
250
  plt.ylim(y_min, y_max, yside="right")
@@ -247,6 +296,28 @@ class BurnupChart(JupyterMixin):
247
296
  else:
248
297
  tick_labels.append(local_now.strftime("%H:%M"))
249
298
 
299
+ # Add "100%" tick if showing the hits-100 line
300
+ if hits_100_hours_for_tick is not None:
301
+ min_distance = display_hours / 10 # Minimum 10% of display width apart
302
+ # Filter out ticks that are too close to "100%"
303
+ filtered = [(pos, label) for pos, label in zip(tick_positions, tick_labels, strict=True)
304
+ if abs(hits_100_hours_for_tick - pos) >= min_distance]
305
+ tick_positions = [pos for pos, _ in filtered]
306
+ tick_labels = [label for _, label in filtered]
307
+ # Add the "100%" tick with timestamp
308
+ hits_100_time = display_start + timedelta(hours=hits_100_hours_for_tick)
309
+ local_hits_100 = hits_100_time.astimezone()
310
+ # Round to nearest minute
311
+ if local_hits_100.second >= 30:
312
+ local_hits_100 = local_hits_100.replace(second=0, microsecond=0) + timedelta(minutes=1)
313
+ else:
314
+ local_hits_100 = local_hits_100.replace(second=0, microsecond=0)
315
+ tick_positions.append(hits_100_hours_for_tick)
316
+ if use_date_format:
317
+ tick_labels.append(local_hits_100.strftime("%a %Hh"))
318
+ else:
319
+ tick_labels.append(local_hits_100.strftime("%H:%M"))
320
+
250
321
  plt.xticks(tick_positions, tick_labels)
251
322
 
252
323
  # No labels on axes
@@ -291,7 +362,7 @@ def create_simple_chart(
291
362
  snapshots: list[UsageSnapshot],
292
363
  width: int = 80,
293
364
  height: int = 15,
294
- since: datetime | None = None,
365
+ since_duration: timedelta | None = None,
295
366
  ) -> str:
296
367
  """Create a simple chart string without Rich integration.
297
368
 
@@ -300,10 +371,10 @@ def create_simple_chart(
300
371
  snapshots: Historical snapshots
301
372
  width: Chart width
302
373
  height: Chart height
303
- since: Only show data since this time
374
+ since_duration: Duration for zoom view (sliding window)
304
375
 
305
376
  Returns:
306
377
  Rendered chart string
307
378
  """
308
- chart = BurnupChart(limit_data, snapshots, since=since, explicit_height=height)
379
+ chart = BurnupChart(limit_data, snapshots, since_duration=since_duration, explicit_height=height)
309
380
  return chart._create_chart(width, height)
ccburn/display/gauges.py CHANGED
@@ -1,5 +1,6 @@
1
1
  """Progress bar gauges for ccburn TUI."""
2
2
 
3
+ import os
3
4
  import sys
4
5
 
5
6
  from rich.progress import ProgressBar
@@ -17,23 +18,51 @@ except ImportError:
17
18
  from ccburn.utils.formatting import format_reset_time, get_utilization_color
18
19
 
19
20
 
21
+ _emoji_support_cache: bool | None = None
22
+
23
+
20
24
  def _supports_emoji() -> bool:
21
25
  """Detect if the console supports emoji characters.
22
26
 
27
+ Result is cached to ensure consistent behavior throughout the session,
28
+ as Rich's Live mode may affect stdout properties during rendering.
29
+
23
30
  Returns:
24
31
  True if emoji are likely supported, False otherwise.
25
32
  """
26
- # Check if stdout encoding supports emoji
33
+ global _emoji_support_cache
34
+ if _emoji_support_cache is not None:
35
+ return _emoji_support_cache
36
+
37
+ # First, check if stdout encoding can handle emoji - this prevents crashes
38
+ # even in modern terminals if Python's encoding is misconfigured
27
39
  try:
28
40
  encoding = getattr(sys.stdout, "encoding", None) or ""
29
- if encoding.lower() in ("utf-8", "utf8"):
30
- return True
31
- # Try to encode an emoji to test
32
- "🔥".encode(encoding)
33
- return True
41
+ if encoding.lower() not in ("utf-8", "utf8"):
42
+ # Try to encode an emoji to test
43
+ "🔥".encode(encoding)
34
44
  except (UnicodeEncodeError, LookupError):
45
+ # Encoding cannot handle emoji - use ASCII to prevent crashes
46
+ _emoji_support_cache = False
35
47
  return False
36
48
 
49
+ # Encoding is OK, now check for modern terminal environments
50
+ # that are known to render emoji correctly
51
+ if os.environ.get("WT_SESSION"): # Windows Terminal
52
+ _emoji_support_cache = True
53
+ return True
54
+ if os.environ.get("TERM_PROGRAM"): # macOS Terminal, iTerm2, VS Code, etc.
55
+ _emoji_support_cache = True
56
+ return True
57
+ if os.environ.get("COLORTERM") == "truecolor": # Modern terminals with truecolor
58
+ _emoji_support_cache = True
59
+ return True
60
+
61
+ # Fall back to utf-8 encoding check
62
+ encoding = getattr(sys.stdout, "encoding", None) or ""
63
+ _emoji_support_cache = encoding.lower() in ("utf-8", "utf8")
64
+ return _emoji_support_cache
65
+
37
66
 
38
67
  def get_pace_emoji(utilization: float, budget_pace: float, ascii_fallback: bool = False) -> str:
39
68
  """Get emoji indicator based on utilization vs budget pace.
ccburn/display/layout.py CHANGED
@@ -1,7 +1,7 @@
1
1
  """Layout manager for ccburn TUI."""
2
2
 
3
3
  import os
4
- from datetime import datetime, timezone
4
+ from datetime import datetime, timedelta, timezone
5
5
 
6
6
  from rich.console import Console
7
7
  from rich.layout import Layout
@@ -68,7 +68,7 @@ class BurnupLayout:
68
68
  snapshots: list[UsageSnapshot],
69
69
  error: str | None = None,
70
70
  stale_since: datetime | None = None,
71
- since: datetime | None = None,
71
+ since_duration: timedelta | None = None,
72
72
  ) -> Layout:
73
73
  """Update the layout with new data.
74
74
 
@@ -78,7 +78,7 @@ class BurnupLayout:
78
78
  snapshots: Historical snapshots for chart
79
79
  error: Error message to display (if any)
80
80
  stale_since: When data became stale (if using cached data)
81
- since: Zoom view start time
81
+ since_duration: Zoom view duration (sliding window)
82
82
 
83
83
  Returns:
84
84
  Updated Rich Layout
@@ -97,14 +97,14 @@ class BurnupLayout:
97
97
  if self.should_use_compact_mode():
98
98
  return self._create_compact_layout(limit_type, width, height)
99
99
 
100
- return self._create_full_layout(limit_type, width, height, since)
100
+ return self._create_full_layout(limit_type, width, height, since_duration)
101
101
 
102
102
  def _create_full_layout(
103
103
  self,
104
104
  limit_type: LimitType,
105
105
  width: int,
106
106
  height: int,
107
- since: datetime | None = None,
107
+ since_duration: timedelta | None = None,
108
108
  ) -> Layout:
109
109
  """Create full TUI layout with header, gauges, and chart.
110
110
 
@@ -112,7 +112,7 @@ class BurnupLayout:
112
112
  limit_type: Which limit to display
113
113
  width: Terminal width
114
114
  height: Terminal height
115
- since: Zoom view start time
115
+ since_duration: Zoom view duration (sliding window)
116
116
 
117
117
  Returns:
118
118
  Rich Layout
@@ -155,8 +155,9 @@ class BurnupLayout:
155
155
  chart = BurnupChart(
156
156
  self._last_limit_data,
157
157
  self._last_snapshots,
158
- since=since,
158
+ since_duration=since_duration,
159
159
  explicit_height=chart_height,
160
+ burn_metrics=self._last_metrics,
160
161
  )
161
162
  chart_layout.update(chart)
162
163
 
@@ -41,53 +41,86 @@ def calculate_budget_pace(resets_at: datetime, window_hours: float) -> float:
41
41
  def calculate_burn_rate(
42
42
  snapshots: list[UsageSnapshot],
43
43
  limit_type: LimitType,
44
- window_minutes: int = 5,
44
+ window_start: datetime,
45
+ window_hours: float,
46
+ min_points: int = 3,
47
+ min_span_pct: float = 0.10,
45
48
  ) -> float:
46
- """Calculate burn rate as percentage points per hour.
49
+ """Calculate burn rate as percentage points per hour using linear regression.
47
50
 
48
- Uses simple linear calculation over recent snapshots.
51
+ Uses least-squares linear regression over snapshots from the current window
52
+ for an accurate burn rate estimate.
49
53
 
50
54
  Args:
51
55
  snapshots: List of usage snapshots (should be sorted by timestamp)
52
56
  limit_type: Which limit to calculate burn rate for
53
- window_minutes: How far back to look for calculation
57
+ window_start: Start of the current window (from limit_data)
58
+ window_hours: Duration of the window in hours
59
+ min_points: Minimum data points required for regression (default 3)
60
+ min_span_pct: Minimum time span as fraction of window (default 0.10 = 10%)
54
61
 
55
62
  Returns:
56
63
  Burn rate in percentage points per hour (e.g., 12.5 means 12.5%/hour)
64
+ Returns 0.0 if insufficient data (no projection should be shown)
57
65
  """
58
66
  if len(snapshots) < 2:
59
67
  return 0.0
60
68
 
61
- now = datetime.now(timezone.utc)
62
- cutoff = now - timedelta(minutes=window_minutes)
63
-
64
- # Filter to recent snapshots
65
- recent = [s for s in snapshots if s.timestamp >= cutoff]
66
-
67
- if len(recent) < 2:
68
- # Fall back to any 2 snapshots if not enough recent ones
69
- recent = snapshots[-2:] if len(snapshots) >= 2 else []
70
-
71
- if len(recent) < 2:
69
+ # Use actual window start, not now - window_minutes
70
+ cutoff = window_start
71
+
72
+ # Filter to snapshots within window and extract (time, utilization) pairs
73
+ points: list[tuple[float, float]] = [] # (hours_from_start, utilization_pct)
74
+ first_timestamp = None
75
+ last_timestamp = None
76
+
77
+ for s in snapshots:
78
+ if s.timestamp < cutoff:
79
+ continue
80
+ limit = s.get_limit(limit_type)
81
+ if limit is None:
82
+ continue
83
+ if first_timestamp is None:
84
+ first_timestamp = s.timestamp
85
+ last_timestamp = s.timestamp
86
+ hours = (s.timestamp - first_timestamp).total_seconds() / 3600
87
+ points.append((hours, limit.utilization * 100))
88
+
89
+ # Need at least min_points for meaningful regression
90
+ if len(points) < min_points:
72
91
  return 0.0
73
92
 
74
- # Get utilization for the specified limit type
75
- first = recent[0]
76
- last = recent[-1]
77
-
78
- first_limit = first.get_limit(limit_type)
79
- last_limit = last.get_limit(limit_type)
80
-
81
- if first_limit is None or last_limit is None:
93
+ # Check that data spans at least min_span_pct of the window
94
+ # e.g., for 5h session at 10%, need 30 min of data
95
+ # e.g., for 168h weekly at 10%, need ~17 hours of data
96
+ if first_timestamp and last_timestamp:
97
+ span_hours = (last_timestamp - first_timestamp).total_seconds() / 3600
98
+ min_span_hours = window_hours * min_span_pct
99
+ if span_hours < min_span_hours:
100
+ return 0.0
101
+
102
+ # If we have exactly 2 points, use simple slope
103
+ if len(points) == 2:
104
+ dx = points[1][0] - points[0][0]
105
+ if dx <= 0:
106
+ return 0.0
107
+ return (points[1][1] - points[0][1]) / dx
108
+
109
+ # Linear regression using least squares
110
+ # slope = (n*Σxy - Σx*Σy) / (n*Σx² - (Σx)²)
111
+ n = len(points)
112
+ sum_x = sum(p[0] for p in points)
113
+ sum_y = sum(p[1] for p in points)
114
+ sum_xy = sum(p[0] * p[1] for p in points)
115
+ sum_x2 = sum(p[0] ** 2 for p in points)
116
+
117
+ denominator = n * sum_x2 - sum_x**2
118
+ if abs(denominator) < 1e-10:
119
+ # All x values are the same (no time elapsed)
82
120
  return 0.0
83
121
 
84
- delta_util = (last_limit.utilization - first_limit.utilization) * 100 # Convert to %
85
- delta_hours = (last.timestamp - first.timestamp).total_seconds() / 3600
86
-
87
- if delta_hours <= 0:
88
- return 0.0
89
-
90
- return delta_util / delta_hours # %/hour
122
+ slope = (n * sum_xy - sum_x * sum_y) / denominator
123
+ return slope # %/hour
91
124
 
92
125
 
93
126
  def estimate_time_to_empty(current_utilization: float, burn_rate_per_hour: float) -> int | None:
@@ -177,20 +210,23 @@ def get_status(utilization: float, budget_pace: float) -> str:
177
210
  def calculate_burn_metrics(
178
211
  limit_data: LimitData,
179
212
  snapshots: list[UsageSnapshot],
180
- window_minutes: int = 5,
181
213
  ) -> BurnMetrics:
182
214
  """Calculate all burn metrics for a limit.
183
215
 
184
216
  Args:
185
217
  limit_data: Current limit data
186
218
  snapshots: Historical snapshots for burn rate calculation
187
- window_minutes: How far back to look for burn rate
188
219
 
189
220
  Returns:
190
221
  BurnMetrics with all calculated values
191
222
  """
192
223
  budget_pace = calculate_budget_pace(limit_data.resets_at, limit_data.window_hours)
193
- burn_rate = calculate_burn_rate(snapshots, limit_data.limit_type, window_minutes)
224
+ burn_rate = calculate_burn_rate(
225
+ snapshots,
226
+ limit_data.limit_type,
227
+ window_start=limit_data.window_start,
228
+ window_hours=limit_data.window_hours,
229
+ )
194
230
  time_to_empty = estimate_time_to_empty(limit_data.utilization, burn_rate)
195
231
  trend = classify_burn_trend(burn_rate)
196
232
  status = get_status(limit_data.utilization, budget_pace)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccburn
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: Terminal-based Claude Code usage limit visualizer with real-time burn-up charts
5
5
  Author: JuanjoFuchs
6
6
  License-Expression: MIT
@@ -45,7 +45,7 @@ Dynamic: license-file
45
45
  [![PyPI](https://img.shields.io/pypi/v/ccburn)](https://pypi.org/project/ccburn/)
46
46
  [![Python](https://img.shields.io/pypi/pyversions/ccburn)](https://pypi.org/project/ccburn/)
47
47
  [![GitHub Release](https://img.shields.io/github/v/release/JuanjoFuchs/ccburn)](https://github.com/JuanjoFuchs/ccburn/releases)
48
- [![WinGet](https://img.shields.io/badge/WinGet-pending-yellow)](https://github.com/microsoft/winget-pkgs/pulls?q=is%3Apr+ccburn)
48
+ [![WinGet](https://img.shields.io/winget/v/JuanjoFuchs.ccburn)](https://winstall.app/apps/JuanjoFuchs.ccburn)
49
49
  [![npm downloads](https://img.shields.io/npm/dt/ccburn?label=npm%20downloads)](https://www.npmjs.com/package/ccburn)
50
50
  [![PyPI downloads](https://img.shields.io/pepy/dt/ccburn?label=pypi%20downloads)](https://pepy.tech/project/ccburn)
51
51
  [![GitHub downloads](https://img.shields.io/github/downloads/JuanjoFuchs/ccburn/total?label=github%20downloads)](https://github.com/JuanjoFuchs/ccburn/releases)
@@ -76,7 +76,7 @@ TUI and CLI for Claude Code usage limits — burn-up charts, compact mode for st
76
76
 
77
77
  Run `claude` and login first to refresh credentials.
78
78
 
79
- ### WinGet (*pending approval*)
79
+ ### WinGet (Windows)
80
80
 
81
81
  ```powershell
82
82
  winget install JuanjoFuchs.ccburn
@@ -1,6 +1,6 @@
1
1
  ccburn/__init__.py,sha256=u8tlHJ2bTam19CROn2nufmcfCONN9BwlRLY-8AOu8Os,191
2
- ccburn/app.py,sha256=FcVHLbnygKbMTGIFZRN5jS_Pve8VdKImfLi2KsDuEoU,15510
3
- ccburn/cli.py,sha256=5qYYmOWcUXNYEdK-E3DBixfWQK4wKIQs6FPvTrIfOVI,7184
2
+ ccburn/app.py,sha256=IvIFOX10AFD103P5XiIenkEB2clAq2o3XJR6lceMVhE,17775
3
+ ccburn/cli.py,sha256=dvh6V8I1TQlqbCv4-5j3ANRdKpoQKPHgA2QrBZUSkJY,7120
4
4
  ccburn/main.py,sha256=TqWLl9xxOtbpTQr-ObomzSLG3jNec2GZ6RKEQYYdGLg,2474
5
5
  ccburn/data/__init__.py,sha256=ZczEZwodQ-MMO5F7fVNsyIpUCRY8Ya9W4pwdOOJWxm4,803
6
6
  ccburn/data/credentials.py,sha256=wDiiTkZZDBjnYspvtWJ_52xXdTdIgBndLlfFMi-peZ8,5228
@@ -8,15 +8,15 @@ ccburn/data/history.py,sha256=ouBxrXpMp_eTs0kba1Bg55TI6bsBSMToJ32tH1wNHQI,12879
8
8
  ccburn/data/models.py,sha256=Sd2T36gH6OaNHl9zRlnnQXI-ziBA8Gl6rPYQIzmr7G4,5403
9
9
  ccburn/data/usage_client.py,sha256=_dGwmI5vYPk4S-HUe2_fnTwSuAfTPaOFff7mKPFnhps,4570
10
10
  ccburn/display/__init__.py,sha256=aL7TV53kU5oxlIwJ8M17stG2aC6UeGB-pj2u5BOpegs,495
11
- ccburn/display/chart.py,sha256=HGDcMqaqyNtlQzV-d-gUOStq8qjpVt4EYvCIU6BTAx8,11983
12
- ccburn/display/gauges.py,sha256=fEFsqPNrbME3isX41f56NZRLLoF2a3ll6_tfU8G0lFA,9440
13
- ccburn/display/layout.py,sha256=UndPxyh32jWGdDgOZCvedz06WcKxYMSchLwpOkkXQKo,8093
11
+ ccburn/display/chart.py,sha256=7Rg9KeeeDjr8jwvEhHbmBYmZ0Wj3N1ijfzsQy6u6kqA,15917
12
+ ccburn/display/gauges.py,sha256=qPjFmTJd3RcwMfpmONFHByno5ByDPaVpvDttHnfct1E,10628
13
+ ccburn/display/layout.py,sha256=DlgmH3G2RbpyYfWYD6YP1IlohYmhWBSiqjM7Y7fto-U,8244
14
14
  ccburn/utils/__init__.py,sha256=N6EzUX9hUJkuga_l9Ci3of1CWNtQgpNmMmNyY2DgYrg,1119
15
- ccburn/utils/calculator.py,sha256=QcFm5X-VWZzucHdInEjjqKV5oZaNsdpMgl8oKvHAQYc,6174
15
+ ccburn/utils/calculator.py,sha256=m-XATeOqgD5Z4ZranuI7QSlSb4pnW43_rtaJSF2Te6Q,7706
16
16
  ccburn/utils/formatting.py,sha256=MEVIohBmvSur0hcc67oyYRDooiUMf0rPa4LO1fc2Ud4,4174
17
- ccburn-0.2.2.dist-info/licenses/LICENSE,sha256=Qf2mqNi2qJ35JytfoTdR1SgYhZ2Mt4Ohcf-tu_MuYC0,1068
18
- ccburn-0.2.2.dist-info/METADATA,sha256=HnLVSePIkdJpyn66BIB7lj-TfyOmlBev_10-1yvsjJI,7373
19
- ccburn-0.2.2.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
20
- ccburn-0.2.2.dist-info/entry_points.txt,sha256=GfFQ5VusMR8RJ9meygqWjaErdmYsf_arbILzf64WjLU,43
21
- ccburn-0.2.2.dist-info/top_level.txt,sha256=SM8TwGQZqQKKIQObVWQkfpA0OI4gRut7bPl-iM3g5RI,7
22
- ccburn-0.2.2.dist-info/RECORD,,
17
+ ccburn-0.3.1.dist-info/licenses/LICENSE,sha256=Qf2mqNi2qJ35JytfoTdR1SgYhZ2Mt4Ohcf-tu_MuYC0,1068
18
+ ccburn-0.3.1.dist-info/METADATA,sha256=UnlFr_QEFUjXnwh0ho0iw_1KHYesD7g_uzOL5DJSWjk,7343
19
+ ccburn-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
20
+ ccburn-0.3.1.dist-info/entry_points.txt,sha256=GfFQ5VusMR8RJ9meygqWjaErdmYsf_arbILzf64WjLU,43
21
+ ccburn-0.3.1.dist-info/top_level.txt,sha256=SM8TwGQZqQKKIQObVWQkfpA0OI4gRut7bPl-iM3g5RI,7
22
+ ccburn-0.3.1.dist-info/RECORD,,
File without changes