Foreign Exchange Rates widget screenshot

Script

Foreign Exchange Rates

by Matthew Charlesworth

# ============================================================
# TerminalWidget β€” USD Exchange Rates
#
# Updates eight TerminalWidget targets:
#
#   exchange-rates   compact summary table
#
#   exchange-zar     30-day ZAR chart
#   exchange-zmw     30-day ZMW chart
#   exchange-zwg     30-day ZWG chart
#   exchange-mzn     30-day MZN chart
#   exchange-mwk     30-day MWK chart
#   exchange-gbp     30-day GBP chart
#   exchange-eur     30-day EUR chart
#
# Data source:
#   fxapi.app
#
# Current rates:
#   One request retrieves all USD rates.
#
# Historical rates:
#   30 daily observations, cached locally once per day.
#
# Recommended LaunchAgent interval:
#   15 minutes
# ============================================================

Script (exchange-rates-widget.sh)

#!/bin/zsh

# ============================================================
# TerminalWidget β€” USD Exchange Rates
#
# Updates eight TerminalWidget targets:
#
#   exchange-rates   compact summary table
#
#   exchange-zar     30-day ZAR chart
#   exchange-zmw     30-day ZMW chart
#   exchange-zwg     30-day ZWG chart
#   exchange-mzn     30-day MZN chart
#   exchange-mwk     30-day MWK chart
#   exchange-gbp     30-day GBP chart
#   exchange-eur     30-day EUR chart
#
# Data source:
#   fxapi.app
#
# Current rates:
#   One request retrieves all USD rates.
#
# Historical rates:
#   30 daily observations, cached locally once per day.
#
# Recommended LaunchAgent interval:
#   15 minutes
# ============================================================

set -u


# ------------------------------------------------------------
# Configuration
# ------------------------------------------------------------

TW="/Applications/TerminalWidget.app/Contents/MacOS/TerminalWidget"

API_BASE="https://fxapi.app/api"

STATE_DIR="$HOME/Library/Application Support/TerminalWidgetFX"

mkdir -p "$STATE_DIR"


# ------------------------------------------------------------
# Currencies
# ------------------------------------------------------------

CURRENCIES=(
    ZAR
    ZMW
    ZWG
    MZN
    MWK
    GBP
    EUR
)


# ------------------------------------------------------------
# Currency flags
# ------------------------------------------------------------

typeset -A FLAG

FLAG=(
    USD "πŸ‡ΊπŸ‡Έ"
    ZAR "πŸ‡ΏπŸ‡¦"
    ZMW "πŸ‡ΏπŸ‡²"
    ZWG "πŸ‡ΏπŸ‡Ό"
    MZN "πŸ‡²πŸ‡Ώ"
    MWK "πŸ‡²πŸ‡Ό"
    GBP "πŸ‡¬πŸ‡§"
    EUR "πŸ‡ͺπŸ‡Ί"
)


# ------------------------------------------------------------
# Dates
#
# Today plus the preceding 29 days gives 30 calendar days.
# ------------------------------------------------------------

TODAY="$(date '+%Y-%m-%d')"

FROM_DATE="$(date -v-29d '+%Y-%m-%d')"

FROM_LABEL="$(date -v-29d '+%d %b')"

TO_LABEL="$(date '+%d %b')"


# Remove leading zero:
#
#   03 Aug -> 3 Aug
# ------------------------------------------------------------

FROM_LABEL="${FROM_LABEL#0}"

TO_LABEL="${TO_LABEL#0}"


# ------------------------------------------------------------
# Locate jq
#
# launchd normally has a restricted PATH, so explicitly
# check the common Homebrew locations.
# ------------------------------------------------------------

if [[ -x "/opt/homebrew/bin/jq" ]]; then

    JQ="/opt/homebrew/bin/jq"

elif [[ -x "/usr/local/bin/jq" ]]; then

    JQ="/usr/local/bin/jq"

else

    JQ="$(command -v jq 2>/dev/null || true)"

fi


if [[ -z "${JQ:-}" || ! -x "$JQ" ]]; then

    echo "Error: jq not found." >&2
    echo >&2
    echo "Install it with:" >&2
    echo >&2
    echo "    brew install jq" >&2

    exit 1

fi


# ------------------------------------------------------------
# Check TerminalWidget
# ------------------------------------------------------------

if [[ ! -x "$TW" ]]; then

    echo "Error: TerminalWidget binary not found:" >&2
    echo >&2
    echo "    $TW" >&2

    exit 1

fi


# ------------------------------------------------------------
# Format exchange rate
#
# Examples:
#
#       16.2387  -> 16.24
#     1735.3298  -> 1,735.33
#    12345.6789  -> 12,345.68
#
# Uses native zsh rather than awk.
# ------------------------------------------------------------

format_rate()
{
    local VALUE="$1"

    local ROUNDED
    local INTEGER
    local DECIMAL
    local SIGN=""
    local GROUPED=""
    local CHUNK


    # Round to exactly two decimal places.

    ROUNDED="$(LC_ALL=C printf "%.2f" "$VALUE")"


    # Separate integer and decimal portions.

    INTEGER="${ROUNDED%%.*}"

    DECIMAL="${ROUNDED##*.}"


    # Preserve negative sign if ever required.

    if [[ "$INTEGER" == -* ]]; then

        SIGN="-"

        INTEGER="${INTEGER#-}"

    fi


    # Insert thousands separators from right to left.

    while (( ${#INTEGER} > 3 )); do

        CHUNK="${INTEGER[-3,-1]}"

        GROUPED=",${CHUNK}${GROUPED}"

        INTEGER="${INTEGER[1,-4]}"

    done


    printf "%s%s%s.%s" \
        "$SIGN" \
        "$INTEGER" \
        "$GROUPED" \
        "$DECIMAL"
}


# ============================================================
#
# CURRENT EXCHANGE RATES
#
# ============================================================


CURRENT_JSON="$STATE_DIR/current-usd.json"

CURRENT_TMP="$STATE_DIR/current-usd.tmp"

CURRENT_STALE=0


# ------------------------------------------------------------
# Fetch all USD rates
#
# One fxapi.app request returns every target currency.
# ------------------------------------------------------------

if /usr/bin/curl \
    --fail \
    --silent \
    --show-error \
    --location \
    --compressed \
    --connect-timeout 5 \
    --max-time 15 \
    "$API_BASE/usd.json" \
    > "$CURRENT_TMP"
then

    # Check that the response looks valid.

    if "$JQ" -e \
        '.rates and (.rates | type == "object")' \
        "$CURRENT_TMP" \
        >/dev/null 2>&1
    then

        mv "$CURRENT_TMP" "$CURRENT_JSON"

    else

        rm -f "$CURRENT_TMP"

        CURRENT_STALE=1

    fi

else

    rm -f "$CURRENT_TMP"

    CURRENT_STALE=1

fi


# ------------------------------------------------------------
# Require either fresh or cached current rates
# ------------------------------------------------------------

if [[ ! -s "$CURRENT_JSON" ]]; then

    echo "Error: no current exchange-rate data available." >&2

    exit 1

fi


# ------------------------------------------------------------
# Absolute path to this script
#
# Clicking any widget runs this command and therefore updates
# all eight widgets.
# ------------------------------------------------------------

SCRIPT_PATH="${0:A}"

REFRESH_COMMAND="/bin/zsh \"$SCRIPT_PATH\""


# ============================================================
#
# SUMMARY WIDGET
#
# Target:
#
#   exchange-rates
#
# IMPORTANT:
#
# TerminalWidget's sandbox may prevent it reading a table file
# directly from ~/Library/Application Support.
#
# We therefore generate TSV and pipe it directly to:
#
#   --table -
#
# TerminalWidget does not support --title with table widgets,
# so the first table row serves as the heading.
#
# ============================================================


{
    # Header row

    printf "Currency\t%s 1 USD\n" "${FLAG[USD]}"


    # Currency rows

    for CURRENCY in $CURRENCIES
    do

        CURRENT_RATE="$(
            "$JQ" -er \
                --arg currency "$CURRENCY" \
                '.rates[$currency] | select(type == "number")' \
                "$CURRENT_JSON" \
                2>/dev/null
        )" || CURRENT_RATE=""


        if [[ -n "$CURRENT_RATE" ]]; then

            DISPLAY_RATE="$(format_rate "$CURRENT_RATE")"


            printf "%s %s\t%s\n" \
                "${FLAG[$CURRENCY]}" \
                "$CURRENCY" \
                "$DISPLAY_RATE"

        fi

    done

} |
    "$TW" \
        --target "exchange-rates" \
        --table - \
        --grid none \
        --table-layout auto \
        --font-size 10 \
        --padding 4 \
        --mode light \
        --bg-gradient-from "#D7EAD9" \
        --bg-gradient-to "#EFF7F0" \
        --bg-gradient-start nw \
        --text-color "#183B26" \
        --title-color "#285D37" \
        --action-kind run-command \
        --action-value "$REFRESH_COMMAND"


# ============================================================
#
# INDIVIDUAL 30-DAY GRAPH WIDGETS
#
# ============================================================


for CURRENCY in $CURRENCIES
do

    LOWER="${(L)CURRENCY}"

    TARGET="exchange-${LOWER}"


    # --------------------------------------------------------
    # Current rate
    # --------------------------------------------------------

    CURRENT_RATE="$(
        "$JQ" -er \
            --arg currency "$CURRENCY" \
            '.rates[$currency] | select(type == "number")' \
            "$CURRENT_JSON" \
            2>/dev/null
    )" || CURRENT_RATE=""


    if [[ -z "$CURRENT_RATE" ]]; then

        echo "Warning: no current USD/$CURRENCY rate." >&2

        continue

    fi


    # --------------------------------------------------------
    # Historical-data files
    #
    # A new cache file is created each calendar day.
    # --------------------------------------------------------

    HISTORY_FILE="$STATE_DIR/history-usd-${LOWER}-${TODAY}.json"

    HISTORY_TMP="$STATE_DIR/history-usd-${LOWER}-${TODAY}.tmp"


    # --------------------------------------------------------
    # Fetch 30-day history
    #
    # Only fetch if today's cached historical response does
    # not already exist.
    # --------------------------------------------------------

    if [[ ! -s "$HISTORY_FILE" ]]; then

        HISTORY_URL="${API_BASE}/history/usd/${LOWER}.json?from=${FROM_DATE}&to=${TODAY}"


        if /usr/bin/curl \
            --fail \
            --silent \
            --show-error \
            --location \
            --compressed \
            --connect-timeout 5 \
            --max-time 20 \
            "$HISTORY_URL" \
            > "$HISTORY_TMP"
        then

            # Confirm that the response contains rate data.

            if "$JQ" -e \
                '(.rates | type == "array") and ((.rates | length) > 0)' \
                "$HISTORY_TMP" \
                >/dev/null 2>&1
            then

                mv "$HISTORY_TMP" "$HISTORY_FILE"

            else

                rm -f "$HISTORY_TMP"

            fi

        else

            rm -f "$HISTORY_TMP"

        fi

    fi


    # --------------------------------------------------------
    # Build 30-day chart
    #
    # Historical observations are sorted by date.
    #
    # If the historical endpoint already contains today's
    # value, today's historical observation is removed.
    #
    # The current live rate is then appended so the final
    # graph point represents NOW.
    # --------------------------------------------------------

    if [[ -s "$HISTORY_FILE" ]]; then

        CHART="$(
            {

                "$JQ" -r \
                    --arg today "$TODAY" \
                    '
                    .rates
                    | sort_by(.date)
                    | .[]
                    | select(.date != $today)
                    | .rate
                    ' \
                    "$HISTORY_FILE"


                printf '%s\n' "$CURRENT_RATE"

            } |
            tail -n 30 |
            paste -sd ' ' -
        )"

    else

        # Historical service unavailable:
        # still display current exchange rate.

        CHART="$CURRENT_RATE"

    fi


    # --------------------------------------------------------
    # Format current rate
    # --------------------------------------------------------

    DISPLAY_RATE="$(format_rate "$CURRENT_RATE")"


    # --------------------------------------------------------
    # Graph title
    #
    # Examples:
    #
    #   πŸ‡ΊπŸ‡Έ USD β†’ πŸ‡ͺπŸ‡Ί EUR Β· 30D
    #   πŸ‡ΊπŸ‡Έ USD β†’ πŸ‡ΏπŸ‡¦ ZAR Β· 30D
    # --------------------------------------------------------

    TITLE="${FLAG[USD]} USD β†’ ${FLAG[$CURRENCY]} ${CURRENCY} Β· 30D"


    # --------------------------------------------------------
    # Centre caption
    # --------------------------------------------------------

    if (( CURRENT_STALE )); then

        CENTER_LABEL="${DISPLAY_RATE} ${CURRENCY} Β· cached"

    else

        CENTER_LABEL="${DISPLAY_RATE} ${CURRENCY}"

    fi


    # --------------------------------------------------------
    # Update individual graph widget
    #
    # --chart-format graph
    #     connected line with point markers
    #
    # --label-y
    #     five numerical Y-axis labels
    #
    # --caption-left/right
    #     beginning/end of 30-day period
    #
    # --caption-text
    #     current exchange rate in centre
    #
    # We deliberately do NOT use --base-zero because FX
    # movements would otherwise be visually flattened.
    # --------------------------------------------------------

    "$TW" \
        --target "$TARGET" \
        --title "$TITLE" \
        --title-alignment center \
        --chart "$CHART" \
        --chart-format graph \
        --chart-height "75%" \
        --label-y \
        --caption-left "$FROM_LABEL" \
        --caption-text "$CENTER_LABEL" \
        --caption-right "$TO_LABEL" \
        --mode light \
        --bg-gradient-from "#D7EAD9" \
        --bg-gradient-to "#EFF7F0" \
        --bg-gradient-start nw \
        --text-color "#183B26" \
        --title-color "#285D37" \
        --action-kind run-command \
        --action-value "$REFRESH_COMMAND"

done


# ============================================================
#
# CLEANUP
#
# ============================================================


# Keep approximately one week's historical cache files.

find "$STATE_DIR" \
    -type f \
    -name 'history-*.json' \
    -mtime +7 \
    -delete \
    2>/dev/null


exit 0

Download script

Running in the background with launchd

Suggested interval: 15 minutes (StartInterval = 900 seconds).

Save the script from this recipe to ~/bin/foreign-exchange-rates.sh, then create a Launch Agent plist:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.terminalwidget.foreign-exchange-rates</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>~/bin/foreign-exchange-rates.sh</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>StartInterval</key>
  <integer>900</integer>
  <key>StandardOutPath</key>
  <string>/tmp/com.terminalwidget.foreign-exchange-rates.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/com.terminalwidget.foreign-exchange-rates.err</string>
</dict>
</plist>

From Terminal:

mkdir -p ~/bin ~/Library/LaunchAgents
# Save your script to ~/bin/foreign-exchange-rates.sh
cat > ~/Library/LaunchAgents/com.terminalwidget.foreign-exchange-rates.plist <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>
  <string>com.terminalwidget.foreign-exchange-rates</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>~/bin/foreign-exchange-rates.sh</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>StartInterval</key>
  <integer>900</integer>
  <key>StandardOutPath</key>
  <string>/tmp/com.terminalwidget.foreign-exchange-rates.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/com.terminalwidget.foreign-exchange-rates.err</string>
</dict>
</plist>
EOF
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.terminalwidget.foreign-exchange-rates.plist

For a GUI editor and troubleshooting, see LaunchControl from soma-zone.

← All recipes