feat(cli): add height-aware compact outage schedule
All checks were successful
Test blackout notifier / test (push) Successful in 5s
Hourly blackout check / check (push) Successful in 5s

This commit is contained in:
2026-07-27 18:03:18 +03:30
parent 0b24d544d3
commit bf09d4d99c
3 changed files with 153 additions and 26 deletions

View File

@@ -232,11 +232,16 @@ The terminal output uses English-only labels and omits the stored address and
reason text. When there are no qualifying events for any bill it prints an `ALL
CLEAR` screen and exits successfully.
Schedule entries use one compact row each. When every outage cannot fit below
the countdown, the dashboard keeps the nearest entries visible and reports how
many additional outages were omitted. Resizing the terminal reveals more rows.
Colors are enabled only when output is attached to a terminal. Set `NO_COLOR` to
disable them explicitly, or set `COLUMNS` to control the rendered width:
disable them explicitly, or set `COLUMNS` and `LINES` to control the rendered
size:
```sh
NO_COLOR=1 COLUMNS=80 ./main.sh
NO_COLOR=1 COLUMNS=80 LINES=24 ./main.sh
```
When `pyfiglet` or `figlet` provides the `univers` font, the countdown uses it

155
main.sh
View File

@@ -74,6 +74,7 @@ Options:
Environment:
NO_COLOR Disable ANSI colors when set
COLUMNS Override the detected output width
LINES Override the detected output height
Required commands: Bash 4.3+, curl, jq, and GNU date
EOF
@@ -415,6 +416,38 @@ terminal_columns() {
printf '%s\n' "$columns"
}
terminal_lines() {
local lines="${LINES:-}"
local terminal_size="" detected_lines=""
if [[ -z "$lines" ]] && command -v stty >/dev/null 2>&1; then
if [[ -r /dev/tty ]]; then
terminal_size="$(stty size 2>/dev/null </dev/tty || true)"
fi
if [[ -z "$terminal_size" && -t 2 ]]; then
terminal_size="$(stty size <&2 2>/dev/null || true)"
fi
if [[ -z "$terminal_size" && -t 0 ]]; then
terminal_size="$(stty size <&0 2>/dev/null || true)"
fi
read -r detected_lines _ <<<"$terminal_size"
if [[ "$detected_lines" =~ ^[0-9]+$ ]]; then
lines="$detected_lines"
fi
fi
if [[ -z "$lines" ]] && command -v tput >/dev/null 2>&1; then
lines="$(tput lines 2>/dev/null || true)"
fi
if [[ ! "$lines" =~ ^[0-9]+$ ]]; then
lines=24
elif ((lines < 10)); then
lines=10
fi
printf '%s\n' "$lines"
}
color_setup() {
RESET=""
BOLD=""
@@ -714,10 +747,29 @@ human_duration() {
printf '%s\n' "$output"
}
compact_reference() {
local bill_id="$1"
local outage_number="$2"
local width="$3"
local reference="$bill_id/$outage_number"
local bill_width
if ((${#reference} <= width)); then
printf '%s' "$reference"
elif ((width > ${#outage_number} + 2)); then
bill_width=$((width - ${#outage_number} - 2))
printf '~%s/%s' "${bill_id: -bill_width}" "$outage_number"
elif ((width > 1)); then
printf '%s~' "${reference:0:width-1}"
else
printf '~'
fi
}
render_event() {
local line="$1"
local now_epoch="$2"
local ignored phase start_epoch stop_epoch record type_label timing
local columns="$2"
local ignored phase start_epoch stop_epoch record type_label reference reference_width
local -a fields
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$line"
@@ -725,18 +777,24 @@ render_event() {
type_label="EMERGENCY"
[[ "${fields[8]}" == "true" ]] && type_label="PLANNED"
if [[ "$phase" == "ONGOING" ]]; then
timing="ends in $(human_duration "$((stop_epoch - now_epoch))")"
if ((columns >= 60)); then
reference_width=$((columns - 47))
reference="$(compact_reference "${fields[0]}" "${fields[1]}" "$reference_width")"
printf '%s%-9s%s %s%-9s%s %-10s %-11s %s\n' \
"$([[ "$phase" == "ONGOING" ]] && printf '%s' "$RED" || printf '%s' "$CYAN")" \
"$phase" "$RESET" \
"$([[ "$type_label" == "PLANNED" ]] && printf '%s' "$GREEN" || printf '%s' "$YELLOW")" \
"$type_label" "$RESET" "${fields[3]}" "${fields[4]}-${fields[5]}" "$reference"
else
timing="starts in $(human_duration "$((start_epoch - now_epoch))")"
reference_width=$((columns - 34))
((reference_width < 1)) && reference_width=1
reference="$(compact_reference "${fields[0]}" "${fields[1]}" "$reference_width")"
printf '%s%-4s%s %-5s %-10s %-11s %s\n' \
"$([[ "$phase" == "ONGOING" ]] && printf '%s' "$RED" || printf '%s' "$CYAN")" \
"$([[ "$phase" == "ONGOING" ]] && printf 'ON' || printf 'UP')" "$RESET" \
"$([[ "$type_label" == "PLANNED" ]] && printf 'PLAN' || printf 'EMERG')" \
"${fields[3]}" "${fields[4]}-${fields[5]}" "$reference"
fi
printf '%s%s%-10s%s %s%-9s%s %s\n' \
"$BOLD" "$([[ "$phase" == "ONGOING" ]] && printf '%s' "$RED" || printf '%s' "$CYAN")" \
"$phase" "$RESET" "$([[ "$type_label" == "PLANNED" ]] && printf '%s' "$GREEN" || printf '%s' "$YELLOW")" \
"$type_label" "$RESET" "$timing"
printf ' Date %s %s - %s\n' "${fields[3]}" "${fields[4]}" "${fields[5]}"
printf ' Reference bill %s / outage %s\n' "${fields[0]}" "${fields[1]}"
}
render_empty() {
@@ -780,7 +838,7 @@ draw_frame() {
if ((TERMINAL_ACTIVE)); then
cleared_frame="${frame//$'\n'/$'\033[K\n'}"
printf '\033[H%s\033[K\n\033[J' "$cleared_frame"
printf '\033[H%s\033[K\033[J' "$cleared_frame"
else
printf '%s\n' "$frame"
fi
@@ -806,13 +864,17 @@ render_dashboard() {
local now_epoch="$1"
local columns="${2:-}"
local countdown_bill_id="${3:-}"
local rule phase start_epoch stop_epoch record remaining banner
local schedule_line ignored
local lines="${4:-}"
local rule phase start_epoch stop_epoch record remaining banner banner_art
local banner_rows fixed_rows available_rows visible_count hidden_count index show_bill_heading=0
color_setup
if [[ -z "$columns" ]]; then
columns="$(terminal_columns)"
fi
if [[ -z "$lines" ]]; then
lines="$(terminal_lines)"
fi
if ((${#SCHEDULE[@]} == 0)); then
render_empty "$columns"
@@ -834,29 +896,69 @@ render_dashboard() {
remaining=$((start_epoch - now_epoch))
fi
banner="$(banner_duration "$remaining")"
if [[ -n "$countdown_bill_id" ]]; then
banner_art="$(print_large_countdown "$banner" "$columns")"
banner_rows="$(awk 'END { print NR }' <<<"$banner_art")"
[[ -n "$countdown_bill_id" ]] && show_bill_heading=1
fixed_rows=$((8 + banner_rows))
((show_bill_heading)) && fixed_rows=$((fixed_rows + 2))
if ((fixed_rows + 2 > lines)); then
banner_art="$banner"
banner_rows=1
fixed_rows=$((8 + banner_rows))
((show_bill_heading)) && fixed_rows=$((fixed_rows + 2))
if ((fixed_rows + 1 > lines)); then
show_bill_heading=0
fi
fi
if ((show_bill_heading)); then
center_line "COUNTDOWN FOR BILL $countdown_bill_id" "$columns"
printf '\n'
fi
printf '%s%s' "$BOLD" "$YELLOW"
print_large_countdown "$banner" "$columns"
printf '%s\n' "$banner_art"
printf '%s' "$RESET"
fi
printf '\n%s%s%s\n' "$CYAN" "$rule" "$RESET"
printf '\n%sOUTAGE SCHEDULE (%d)%s\n\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET"
printf '\n%sOUTAGE SCHEDULE (%d)%s\n' "$BOLD" "${#SCHEDULE[@]}" "$RESET"
if ((columns >= 60)); then
printf '%s%-9s %-9s %-10s %-11s %s%s\n' \
"$BOLD" "STATUS" "TYPE" "DATE" "TIME" "BILL/OUTAGE" "$RESET"
else
printf '%s%-4s %-5s %-10s %-11s %s%s\n' \
"$BOLD" "ST" "TYPE" "DATE" "TIME" "REF" "$RESET"
fi
printf '%s%s%s\n' "$DIM" "$(repeat_character '-' "$columns")" "$RESET"
for schedule_line in "${SCHEDULE[@]}"; do
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$schedule_line"
render_event "$schedule_line" "$now_epoch"
printf '%s\n' "$DIM$(repeat_character '-' "$columns")$RESET"
fixed_rows=$((8 + ${banner_rows:-1}))
((show_bill_heading)) && fixed_rows=$((fixed_rows + 2))
available_rows=$((lines - fixed_rows - 1))
((available_rows < 0)) && available_rows=0
visible_count="${#SCHEDULE[@]}"
hidden_count=0
if ((visible_count > available_rows)); then
if ((available_rows > 0)); then
visible_count=$((available_rows - 1))
else
visible_count=0
fi
hidden_count=$((${#SCHEDULE[@]} - visible_count))
fi
for ((index = 0; index < visible_count; index++)); do
render_event "${SCHEDULE[index]}" "$columns"
done
if ((hidden_count && available_rows > 0)); then
printf '%s... %d more outage%s%s\n' \
"$DIM" "$hidden_count" "$([[ "$hidden_count" == 1 ]] || printf 's')" "$RESET"
fi
printf '%s%s%s\n' "$CYAN" "$rule" "$RESET"
}
main() {
local now_epoch next_refresh=0 refresh_minutes=10 refresh_seconds state_ready=0
local last_rendered=-1 next_transition=0 rebuild_schedule=0 frame key input_fd=0
local display_columns countdown_bill_id=""
local display_columns display_lines render_lines countdown_bill_id=""
while (($#)); do
case "$1" in
@@ -962,9 +1064,14 @@ main() {
fi
display_columns="$(terminal_columns)"
display_lines="$(terminal_lines)"
render_lines="$display_lines"
if [[ -n "$REFRESH_ERROR" ]] && ((render_lines > 12)); then
render_lines=$((render_lines - 2))
fi
frame="$(
if ((state_ready)); then
render_dashboard "$now_epoch" "$display_columns" "$countdown_bill_id"
render_dashboard "$now_epoch" "$display_columns" "$countdown_bill_id" "$render_lines"
else
render_unavailable "$display_columns"
fi

View File

@@ -120,6 +120,8 @@ assert_equal "01 : 01 : 01" "$(banner_duration 3661)" \
"countdowns should include hours, minutes, and seconds"
assert_equal "49 : 30 : 00" "$(banner_duration 178200)" \
"countdowns should use total hours beyond one day"
assert_equal "17" "$(LINES=17 terminal_lines)" \
"the terminal height override should be respected"
FONT_OUTPUT="$(print_large_countdown "00 : 00 : 00" 150)"
assert_contains "$FONT_OUTPUT" ',a8888a,' \
"the countdown should consistently use the univers-style font"
@@ -189,6 +191,17 @@ if [[ "$OUTPUT" == *$'\033['* ]]; then
printf 'FAIL: NO_COLOR output contains ANSI escapes\n' >&2
exit 1
fi
SHORT_OUTPUT="$(NO_COLOR=1 COLUMNS=80 LINES=12 render_dashboard "$NOW_EPOCH" 80 "" 12)"
SHORT_LINE_COUNT="$(awk 'END { print NR }' <<<"$SHORT_OUTPUT")"
assert_equal "12" "$SHORT_LINE_COUNT" \
"a dashboard with extra outages should fit the terminal height"
assert_contains "$SHORT_OUTPUT" '01 : 00 : 00' \
"the countdown should remain visible when schedule rows are limited"
assert_contains "$SHORT_OUTPUT" "... 2 more outages" \
"the dashboard should report schedule rows that do not fit"
SHORT_BOTTOM_LINE="${SHORT_OUTPUT##*$'\n'}"
assert_equal "$(repeat_character '=' 80)" "$SHORT_BOTTOM_LINE" \
"the compact schedule should end with a full-width rule"
WIDE_OUTPUT="$(NO_COLOR=1 COLUMNS=180 render_dashboard "$NOW_EPOCH")"
WIDE_TOP_LINE="${WIDE_OUTPUT%%$'\n'*}"
assert_equal "180" "${#WIDE_TOP_LINE}" \
@@ -196,6 +209,8 @@ assert_equal "180" "${#WIDE_TOP_LINE}" \
WIDE_BOTTOM_LINE="${WIDE_OUTPUT##*$'\n'}"
assert_equal "180" "${#WIDE_BOTTOM_LINE}" \
"the dashboard bottom divider should span the full terminal width"
assert_equal "$(repeat_character '=' 180)" "$WIDE_BOTTOM_LINE" \
"the dashboard should end with the same rule as the countdown section"
FUTURE_NOW="$(TZ=Asia/Tehran date -d '2026-07-18 15:00:00' +%s)"
build_schedule "$FIXTURE" "$FUTURE_NOW"