feat(cli): add height-aware compact outage schedule
This commit is contained in:
@@ -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
|
reason text. When there are no qualifying events for any bill it prints an `ALL
|
||||||
CLEAR` screen and exits successfully.
|
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
|
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
|
```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
|
When `pyfiglet` or `figlet` provides the `univers` font, the countdown uses it
|
||||||
|
|||||||
155
main.sh
155
main.sh
@@ -74,6 +74,7 @@ Options:
|
|||||||
Environment:
|
Environment:
|
||||||
NO_COLOR Disable ANSI colors when set
|
NO_COLOR Disable ANSI colors when set
|
||||||
COLUMNS Override the detected output width
|
COLUMNS Override the detected output width
|
||||||
|
LINES Override the detected output height
|
||||||
|
|
||||||
Required commands: Bash 4.3+, curl, jq, and GNU date
|
Required commands: Bash 4.3+, curl, jq, and GNU date
|
||||||
EOF
|
EOF
|
||||||
@@ -415,6 +416,38 @@ terminal_columns() {
|
|||||||
printf '%s\n' "$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() {
|
color_setup() {
|
||||||
RESET=""
|
RESET=""
|
||||||
BOLD=""
|
BOLD=""
|
||||||
@@ -714,10 +747,29 @@ human_duration() {
|
|||||||
printf '%s\n' "$output"
|
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() {
|
render_event() {
|
||||||
local line="$1"
|
local line="$1"
|
||||||
local now_epoch="$2"
|
local columns="$2"
|
||||||
local ignored phase start_epoch stop_epoch record type_label timing
|
local ignored phase start_epoch stop_epoch record type_label reference reference_width
|
||||||
local -a fields
|
local -a fields
|
||||||
|
|
||||||
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$line"
|
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$line"
|
||||||
@@ -725,18 +777,24 @@ render_event() {
|
|||||||
type_label="EMERGENCY"
|
type_label="EMERGENCY"
|
||||||
[[ "${fields[8]}" == "true" ]] && type_label="PLANNED"
|
[[ "${fields[8]}" == "true" ]] && type_label="PLANNED"
|
||||||
|
|
||||||
if [[ "$phase" == "ONGOING" ]]; then
|
if ((columns >= 60)); then
|
||||||
timing="ends in $(human_duration "$((stop_epoch - now_epoch))")"
|
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
|
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
|
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() {
|
render_empty() {
|
||||||
@@ -780,7 +838,7 @@ draw_frame() {
|
|||||||
|
|
||||||
if ((TERMINAL_ACTIVE)); then
|
if ((TERMINAL_ACTIVE)); then
|
||||||
cleared_frame="${frame//$'\n'/$'\033[K\n'}"
|
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
|
else
|
||||||
printf '%s\n' "$frame"
|
printf '%s\n' "$frame"
|
||||||
fi
|
fi
|
||||||
@@ -806,13 +864,17 @@ render_dashboard() {
|
|||||||
local now_epoch="$1"
|
local now_epoch="$1"
|
||||||
local columns="${2:-}"
|
local columns="${2:-}"
|
||||||
local countdown_bill_id="${3:-}"
|
local countdown_bill_id="${3:-}"
|
||||||
local rule phase start_epoch stop_epoch record remaining banner
|
local lines="${4:-}"
|
||||||
local schedule_line ignored
|
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
|
color_setup
|
||||||
if [[ -z "$columns" ]]; then
|
if [[ -z "$columns" ]]; then
|
||||||
columns="$(terminal_columns)"
|
columns="$(terminal_columns)"
|
||||||
fi
|
fi
|
||||||
|
if [[ -z "$lines" ]]; then
|
||||||
|
lines="$(terminal_lines)"
|
||||||
|
fi
|
||||||
|
|
||||||
if ((${#SCHEDULE[@]} == 0)); then
|
if ((${#SCHEDULE[@]} == 0)); then
|
||||||
render_empty "$columns"
|
render_empty "$columns"
|
||||||
@@ -834,29 +896,69 @@ render_dashboard() {
|
|||||||
remaining=$((start_epoch - now_epoch))
|
remaining=$((start_epoch - now_epoch))
|
||||||
fi
|
fi
|
||||||
banner="$(banner_duration "$remaining")"
|
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"
|
center_line "COUNTDOWN FOR BILL $countdown_bill_id" "$columns"
|
||||||
printf '\n'
|
printf '\n'
|
||||||
fi
|
fi
|
||||||
printf '%s%s' "$BOLD" "$YELLOW"
|
printf '%s%s' "$BOLD" "$YELLOW"
|
||||||
print_large_countdown "$banner" "$columns"
|
printf '%s\n' "$banner_art"
|
||||||
printf '%s' "$RESET"
|
printf '%s' "$RESET"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '\n%s%s%s\n' "$CYAN" "$rule" "$RESET"
|
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
|
fixed_rows=$((8 + ${banner_rows:-1}))
|
||||||
IFS=$'\t' read -r ignored phase start_epoch stop_epoch record <<<"$schedule_line"
|
((show_bill_heading)) && fixed_rows=$((fixed_rows + 2))
|
||||||
render_event "$schedule_line" "$now_epoch"
|
available_rows=$((lines - fixed_rows - 1))
|
||||||
printf '%s\n' "$DIM$(repeat_character '-' "$columns")$RESET"
|
((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
|
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() {
|
main() {
|
||||||
local now_epoch next_refresh=0 refresh_minutes=10 refresh_seconds state_ready=0
|
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 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
|
while (($#)); do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -962,9 +1064,14 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
display_columns="$(terminal_columns)"
|
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="$(
|
frame="$(
|
||||||
if ((state_ready)); then
|
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
|
else
|
||||||
render_unavailable "$display_columns"
|
render_unavailable "$display_columns"
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ assert_equal "01 : 01 : 01" "$(banner_duration 3661)" \
|
|||||||
"countdowns should include hours, minutes, and seconds"
|
"countdowns should include hours, minutes, and seconds"
|
||||||
assert_equal "49 : 30 : 00" "$(banner_duration 178200)" \
|
assert_equal "49 : 30 : 00" "$(banner_duration 178200)" \
|
||||||
"countdowns should use total hours beyond one day"
|
"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)"
|
FONT_OUTPUT="$(print_large_countdown "00 : 00 : 00" 150)"
|
||||||
assert_contains "$FONT_OUTPUT" ',a8888a,' \
|
assert_contains "$FONT_OUTPUT" ',a8888a,' \
|
||||||
"the countdown should consistently use the univers-style font"
|
"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
|
printf 'FAIL: NO_COLOR output contains ANSI escapes\n' >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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_OUTPUT="$(NO_COLOR=1 COLUMNS=180 render_dashboard "$NOW_EPOCH")"
|
||||||
WIDE_TOP_LINE="${WIDE_OUTPUT%%$'\n'*}"
|
WIDE_TOP_LINE="${WIDE_OUTPUT%%$'\n'*}"
|
||||||
assert_equal "180" "${#WIDE_TOP_LINE}" \
|
assert_equal "180" "${#WIDE_TOP_LINE}" \
|
||||||
@@ -196,6 +209,8 @@ assert_equal "180" "${#WIDE_TOP_LINE}" \
|
|||||||
WIDE_BOTTOM_LINE="${WIDE_OUTPUT##*$'\n'}"
|
WIDE_BOTTOM_LINE="${WIDE_OUTPUT##*$'\n'}"
|
||||||
assert_equal "180" "${#WIDE_BOTTOM_LINE}" \
|
assert_equal "180" "${#WIDE_BOTTOM_LINE}" \
|
||||||
"the dashboard bottom divider should span the full terminal width"
|
"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)"
|
FUTURE_NOW="$(TZ=Asia/Tehran date -d '2026-07-18 15:00:00' +%s)"
|
||||||
build_schedule "$FIXTURE" "$FUTURE_NOW"
|
build_schedule "$FIXTURE" "$FUTURE_NOW"
|
||||||
|
|||||||
Reference in New Issue
Block a user