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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
host = "10.0.4.20" # USB-Ethernet default; set your LAN IP for Wi-Fi

[calendar_countdown]
poll_seconds = 60
poll_seconds = 10 # ambient-tier redraw cadence (default: 10) -- matches the running-CI
# overlay's 10s dwell gap so this app's redraws reliably land inside
# it for near-true alternation (see busybar/display.py)
lookahead_hours = 12
warn_minutes = 5 # bar/countdown turn red within N minutes of start
notice_minutes = 15 # bar/countdown turn amber within N minutes of start
Expand All @@ -17,4 +19,8 @@ auto_busy = false
poll_seconds = 120
repos = ["your-user/your-repo"]
show_green = false
# stale_queued_minutes = 15 # omit to disable stuck-queue detection
# stale_queued_minutes = 15 # omit to disable stuck-queue detection
show_running = true # show a badge (alternating with the calendar) while a run is in progress
running_poll_seconds = 20 # poll interval while a run is active (shortened from poll_seconds)
show_quota = true # GraphQL/REST quota frames join the overlay rotation while a run
# is active (no effect if show_running is false)
352 changes: 352 additions & 0 deletions docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md

Large diffs are not rendered by default.

21 changes: 18 additions & 3 deletions integrations/calendar_countdown/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ This integration polls your macOS calendar for upcoming events and displays a li
- **Countdown** — a large minutes-granular countdown, same size as the time/ends numeral (they always change together): `"54m"` under an hour, `"1h05m"` at/above an hour, falling back to hour-only (`"9h"`) whenever the combined form would run too wide for the space available — which font-width measurement shows can happen even for some single-digit-hour values, not just at 10+ hours. Counts to the event start while upcoming, or to its end once in progress; re-rendered each poll rather than ticking natively on-device, so it updates on the same cadence as the rest of the display (`poll_seconds`).
- **Four states** — `normal`, `notice` (within `notice_minutes` of start), `warning` (within `warn_minutes` of start), and `in_progress`, each with its own background gradient, title color, drain-track gradient, divider color, and digit color. See the design spec (`docs/superpowers/specs/2026-08-03-calendar-ci-integrations-design.md`) for the full palette table and row-budget diagram.

The integration looks ahead 12 hours by default and draws at priority 20 on the display. If an active BUSY session exists on the device, the calendar event display is suppressed in favor of the busy state (priority 90).
The integration looks ahead 12 hours by default and draws at the ambient tier (`busybar.display.PRIORITY_AMBIENT`, priority 20) on the display. If an active BUSY session exists on the device, the calendar event display is suppressed in favor of the busy state (priority 90). If the `ci_status` integration is also running with `show_running` and/or `show_quota` enabled, the calendar and the overlay rotation (running badge, GraphQL/REST quota frames) trade the screen back and forth for as long as a CI run is active — see `ci_status`'s README ("Display Priority Tiers" / alternation rhythm) for the measured numbers; the panel still goes fully dark for a few seconds in most cycles because the firmware never restores an occluded element on its own (see "Display Priority Tiers" below), but at the tuned 10s ambient poll the calendar now recovers the screen in roughly 3 of every 4 overlay dwell gaps rather than effectively never.

## Requirements

Expand Down Expand Up @@ -55,7 +55,8 @@ Edit `config.toml` and configure the `[calendar_countdown]` section:

```toml
[calendar_countdown]
poll_seconds = 60 # how often to check the calendar (default: 60)
poll_seconds = 10 # how often to check the calendar (default: 10 -- ambient-tier
# redraw cadence; see "Display Priority Tiers" below)
lookahead_hours = 12 # how far ahead to scan (default: 12)
warn_minutes = 5 # bar/countdown turn red within N minutes of start (default: 5)
notice_minutes = 15 # bar/countdown turn amber within N minutes of start (default: 15)
Expand All @@ -79,7 +80,7 @@ Verify that the output shows your next upcoming event with the correct countdown

| Key | Type | Default | Purpose |
|---|---|---|---|
| `poll_seconds` | integer | 60 | Polling interval in seconds |
| `poll_seconds` | integer | 10 | Polling interval in seconds (ambient-tier redraw cadence; raise it if 10s polling is more than your calendar setup needs, but see "Display Priority Tiers" below for the alternation tradeoff) |
| `lookahead_hours` | integer | 12 | Hours into the future to scan for events |
| `warn_minutes` | integer | 5 | Bar/countdown turn red when within N minutes of event start |
| `notice_minutes` | integer | 15 | Bar/countdown turn amber when within N minutes of event start |
Expand Down Expand Up @@ -120,3 +121,17 @@ Stdout and stderr are redirected to `~/Library/Logs/busybar/calendar.log`. View
```bash
tail -f ~/Library/Logs/busybar/calendar.log
```

At the default 10s poll cadence, most polls redraw the same event unchanged. To avoid multiplying the log's line rate versus the old 60s cadence sixfold, draw summaries are logged at `INFO` only when the summary actually changes or every 10 minutes (a heartbeat line), and at `DEBUG` otherwise -- `DEBUG` isn't emitted by the default log level, so routine unchanged polls don't appear in `calendar.log` at all. Run with `python -m logging` verbosity raised, or check the process's own stdout in the foreground, if you need to see every single poll.

## Display Priority Tiers

This integration draws at the **ambient** tier (`busybar.display.PRIORITY_AMBIENT`, priority 20) -- see `src/busybar/display.py` for the full shared priority ladder used across every busybar integration, and the design spec's "Display tier framework" section for the two firmware facts (measured, not assumed) that shape it: a different app can only preempt this one with a strictly higher priority (equal priority is rejected outright), and once preempted, this app's elements are evicted rather than restored -- the calendar only gets the screen back via its own next scheduled redraw, never automatically. The 10s default poll interval exists specifically so those redraws happen often enough to reliably interleave with the `ci_status` overlay tier (priority 21, ~10s dwell gaps, now rotating through the running badge plus two quota frames -- see `ci_status`'s README) during an active CI run. On-device re-measurement across three poll settings, sampled every 2s for ~130s against the live agent:

| Calendar `poll_seconds` | Dwell cycles recovered | Sample split (BADGE / BLANK / OTHER=calendar) |
|---|---|---|
| 60 (pre-v1.5 baseline) | 0 of 6 | 33 / 31 / 0 |
| 15 | 2 of 6 | 34 / 26 / 5 |
| 10 (current default) | 4 of 6 | 35 / 20 / 10 |

Matching the poll to the dwell gap exactly (10s) did not eliminate the dark gaps entirely -- the two timers still run independently with no cross-process coordination, so recovery timing within a gap varies (observed roughly 2-8s into a given 10s gap) and 2 of the 6 sampled cycles still showed no recovery at all -- but it took the calendar from "never recovers" to "recovers in most cycles." A separate on-device run exercising the full 3-frame overlay rotation (running badge -> GraphQL quota -> REST quota -> repeat) at the same 10s dwell showed the same pattern: the calendar reclaimed 3 of the 4 gap windows sampled. If your setup still shows the panel dark for more than a few seconds at a stretch, that is consistent with this measurement, not a bug; lowering `poll_seconds` further has diminishing returns since the elements' own render/transmit latency puts a floor on how tightly the two timers can align.
34 changes: 31 additions & 3 deletions integrations/calendar_countdown/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,17 @@

from busybar.client import BusyBarClient, DrawResult
from busybar.config import load_config
from busybar.display import PRIORITY_AMBIENT, ambient_timeout

from .logic import (ascii_safe, build_elements, select_active_event,
select_next_event)

APP = "calendar_countdown"
PRIORITY = 20
# Ambient-tier priority (see busybar.display for the full ladder contract
# and the two firmware facts it's built on). Was a local PRIORITY=20
# constant before v1.5's shared display-tier framework.
PRIORITY = PRIORITY_AMBIENT
HEARTBEAT_SECONDS = 600
log = logging.getLogger(APP)


Expand All @@ -40,7 +45,7 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool,
every poll.
"""
c = cfg["calendar_countdown"]
timeout_s = int(c["poll_seconds"] * 1.5)
timeout_s = ambient_timeout(c["poll_seconds"])
events = fetch(c["lookahead_hours"])
active = select_active_event(events, now)

Expand Down Expand Up @@ -94,6 +99,21 @@ def run_once(client, fetch, cfg: dict, now: datetime, dry_run: bool,
return f"drew {label} -> {result.value}"


def should_log_info(summary: str, last_logged_summary: str | None,
seconds_since_heartbeat: float,
heartbeat_seconds: int = HEARTBEAT_SECONDS) -> bool:
"""Log-noise control for the v1.5 poll-cadence drop (poll_seconds
60 -> 10 as the ambient-tier default): at 10s polling, logging every
summary at INFO would sixfold the audit log's line rate versus the
old 60s cadence for no new information on most polls (the summary is
usually identical poll to poll). INFO only when the summary actually
changed since the last INFO line, or a heartbeat interval has elapsed
(so a long unchanging run still leaves a periodic "yes, I'm alive"
trail) -- DEBUG otherwise.
"""
return summary != last_logged_summary or seconds_since_heartbeat >= heartbeat_seconds


def main() -> int:
parser = argparse.ArgumentParser(description="BUSY Bar calendar countdown")
parser.add_argument("--once", action="store_true")
Expand Down Expand Up @@ -131,9 +151,17 @@ def main() -> int:

backoff = 5
state: dict = {}
last_logged_summary: str | None = None
last_heartbeat = time.monotonic()
while True:
summary = run_once(client, fetch, cfg, datetime.now(timezone.utc), args.dry_run, state=state)
log.info(summary)
now_monotonic = time.monotonic()
if args.once or should_log_info(summary, last_logged_summary, now_monotonic - last_heartbeat):
log.info(summary)
last_logged_summary = summary
last_heartbeat = now_monotonic
else:
log.debug(summary)
if args.once:
return 0
if summary.endswith(DrawResult.UNREACHABLE.value):
Expand Down
95 changes: 94 additions & 1 deletion integrations/ci_status/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

## What It Does

This integration monitors GitHub Actions workflows across your repositories and displays CI status on the busybar device. When workflows fail, the device shows a full-panel red badge (rounded background + bold white text) listing the affected `repo:workflow` pairs. When queued runs become stale (stuck due to offline runners or capacity), the device shows a full-panel amber badge with black text instead. Long lists scroll. The integration displays at priority 60, but an active BUSY session (priority 90) will override the display to show a blinking red status LED instead.
This integration monitors GitHub Actions workflows across your repositories and displays CI status on the busybar device. When workflows fail, the device shows a full-panel red badge (rounded background + bold white text) listing the affected `repo:workflow` pairs. When queued runs become stale (stuck due to offline runners or capacity), the device shows a full-panel amber badge with black text instead. Long lists scroll. The integration displays at the **alert** tier (`busybar.display.PRIORITY_ALERT`, priority 60), but an active BUSY session (priority 90) will override the display to show a blinking red status LED instead.

**While a run is actively in progress** (and nothing is failing or stuck), the device shows a rotating set of **overlay-tier** frames instead: a cyan/blue "running" badge (repo, PR number or branch, and workflow name across the top; an ETA countdown below; a thin progress line tracking elapsed time against the workflow's typical duration), followed by two GitHub API quota frames (`show_quota`) if enabled. These three frames share one dwell/gap rotation with the ambient-tier `calendar_countdown` integration — see "Display Priority Tiers" below for the shared framework this is built on, and "Overlay Rotation: Running Badge + Quota Frames" for content, config, and the measured alternation rhythm.

## Requirements

Expand Down Expand Up @@ -45,6 +47,10 @@ poll_seconds = 120 # how often to check workflows (default: 120)
repos = ["your-user/your-repo"] # list of repos to monitor
show_green = false # display green builds (default: false)
# stale_queued_minutes = 15 # optional: alert if runs stuck queued for N minutes
show_running = true # show a badge while a run is in progress (default: true)
running_poll_seconds = 20 # poll interval while a run is active (default: 20)
show_quota = true # GraphQL/REST quota frames join the overlay rotation while a
# run is active (default: true; no effect if show_running is false)
```

At minimum, set `repos` to the repositories you want to monitor (e.g., `["owner/repo1", "owner/repo2"]`).
Expand Down Expand Up @@ -72,6 +78,93 @@ Once the foreground test completes, your `config.toml` is in place and GitHub au
| `repos` | array of strings | — | GitHub repositories to monitor in `owner/repo` format (required) |
| `show_green` | boolean | false | Display successful/green workflow status (default: off to reduce noise) |
| `stale_queued_minutes` | integer | (disabled) | Alert if a workflow run has been queued for N minutes without starting (optional; useful to catch offline self-hosted runners) |
| `show_running` | boolean | true | Show the running-CI badge while a run is `in_progress` (across all configured repos; most-recently-started wins, `+N` if others are also running) |
| `running_poll_seconds` | integer | 20 | Poll interval while a run is active (shortened from `poll_seconds`) |
| `show_quota` | boolean | true | Join two GitHub API quota frames (GraphQL, REST) to the overlay rotation while a run is active. No effect if `show_running` is false — the quota frames only ever appear as part of that same rotation. |

## Display Priority Tiers

This integration's alert badges (failure/stuck) and its overlay-tier
frames (running badge, quota frames) both draw through the shared
priority ladder in `src/busybar/display.py`, along with two firmware
facts (measured, not assumed — see the design spec's "Display tier
framework" section for the probe that found them):

- **Equal priority from a different `application_name` is rejected
outright**, not treated as a hand-off, contrary to what the device's own
API documentation claims. This is why the overlay tier lives at its own
priority (`PRIORITY_OVERLAY`, 21) strictly above the calendar's ambient
tier (`PRIORITY_AMBIENT`, 20) rather than reusing it.
- **A preempted app's elements are evicted, not restored.** Once an
overlay-tier draw's own timeout expires, the panel goes dark; the
calendar's last draw does not silently reappear underneath. The calendar
only gets the screen back via its own next scheduled redraw landing in
that dark gap — see `calendar_countdown`'s README ("Display Priority
Tiers") for the tuning history and measured recovery rates.

The alert tier (`PRIORITY_ALERT`, 60) sits above the overlay tier and
preempts it unconditionally — a failure or stuck-queue badge always wins
over the running badge or a quota frame, per the precedence in
`build_ci_payload` (failure > stuck > overlay > quiet green > nothing).

## Overlay Rotation: Running Badge + Quota Frames

While any configured repo has an `in_progress` run (and nothing is failing
or stuck), the device rotates through up to three overlay-tier frames, one
per dwell slot (`OVERLAY_DWELL_SECONDS`, 10s), before repeating:

1. **Running badge** (always first, always present when `show_running` is
on): `REPO #PR WORKFLOW` (or `REPO branch-name WORKFLOW` for
fork/push-triggered runs, which don't have a PR number) across the top,
with `+N` appended if other runs are also active; an ETA below (`~4m`,
`~1h05m` — reusing the calendar countdown's own formatter — or `soon`
once the estimate is under a minute, or `3m in` when there's no
successful-run history yet to estimate from); and a thin progress line
tracking elapsed time against the workflow's typical duration (median
of its last 5 successful runs, cached for the life of the process).
2. **GraphQL quota** (`show_quota`): title ribbon `GITHUB GRAPHQL`, a track bar
showing the fraction of the bucket used, and two numerals — percentage
*remaining* on the left, reset-in on the right (e.g. `18%` / `42m`).
3. **REST quota** (`show_quota`): identical layout, title ribbon `GITHUB REST`.

Each quota frame is built from a single `GET /rate_limit` call, fetched
fresh once per `running_poll_seconds` cycle while a run is active — this
endpoint is explicitly **exempt from GitHub's own rate limiting**, so
polling it does not consume any other quota pool. If that fetch fails, or
the last successful fetch is more than 5 minutes stale, the quota frames
are silently dropped from that cycle's rotation (never a crash, never
stale numbers on screen) — the running badge keeps rotating on its own.
Percentages and reset countdowns are the only numbers shown; no token,
username, or other account-identifying text ever appears in a quota
frame (both fields are computed purely from the numeric `remaining` /
`limit` / `reset` values in the API response).

**Headroom theming.** Each quota frame's background gradient, track-fill
color, title color, and numeral color all key off remaining-quota
headroom, computed from the same fetch:

| Headroom | Remaining | Background gradient | Title / numeral / track-fill |
|---|---|---|---|
| High | > 50% | `#031F17` → `#000A08` (teal-black) | `#6FFFCF` / `#7CFFE0` / `#33FFC1` |
| Medium | 20–50% | `#231400` → `#0A0400` (amber-black) | `#FFCB6B` / `#FFD98C` / `#FFB300` |
| Low | < 20% | `#2E0509` → `#0A0101` (red-black) | `#FF6B7A` / `#FF8A96` / `#FF3B4E` |

**Important: none of this alternates cleanly with the calendar**, for the
same firmware reasons as the running badge alone did before quota frames
existed — see "Display Priority Tiers" above. In practice, while CI is
running, expect the panel to spend roughly half its time showing an
overlay-tier frame (running badge or a quota frame) and the rest either
dark or reclaimed by the calendar, not a clean three-way handoff. On-device
re-measurement after tuning the calendar's own poll interval to 10s (see
`calendar_countdown`'s README for the full three-round table) found the
calendar recovering 4 of 6 sampled dwell gaps in a standalone measurement,
and 3 of 4 gap windows in a separate run that exercised the full 3-frame
rotation end to end — draw sequence `ci_badge → quota_gql → quota_rest →
ci_badge`, each landing ~20s apart (10s dwell + 10s gap), confirmed
against the live device. This is a known limitation of the current
zero-cross-process-coordination design, not a bug; the fixed 10s dwell
(`OVERLAY_DWELL_SECONDS` in `src/busybar/display.py`) and the calendar's
own `poll_seconds` are the two knobs that shape the ratio.

### Stale Queued Detection

Expand Down
Loading
Loading