AppFigures Sales Dashboard widget screenshot

Script

AppFigures Sales Dashboard

by Brett Terpstra

Track Mac App Store downloads and revenue from AppFigures and push them to three Terminal Widget targets: a daily table and two sparkline charts.

Requirements

1. Create an AppFigures API token

  1. Log in at appfigures.com.
  2. Open Account → API → Keys (or go directly to appfigures.com/developers/keys).
  3. Create a Personal Access Token with at least the private:read scope.
  4. Copy the token (it starts with pat_). Store it only in your local config file, not in the script.

Optional — limit to specific apps: On an AppFigures product page, the numeric product ID appears in the URL or product details. Comma-separate IDs if you track multiple apps but want the widget to show only some of them.

2. Create dashboard.env

Create a config file that the script loads automatically:

mkdir -p ~/.config/terminal-widget
chmod 700 ~/.config/terminal-widget

Create ~/.config/terminal-widget/dashboard.env:

# Required: AppFigures Personal Access Token (private:read)
export APPFIGURES_ACCESS_TOKEN='pat_your_token_here'

# Optional: comma-separated product IDs (omit to include all products)
# export APPFIGURES_PRODUCT_IDS='123456,789012'

# Optional: widget target names (must match targets in Terminal Widget)
# export APPFIGURES_TABLE_TARGET='appfigures-table'
# export APPFIGURES_DL_CHART_TARGET='appfigures-downloads'
# export APPFIGURES_REV_CHART_TARGET='appfigures-revenue'

# Optional: how many days to show
# export APPFIGURES_TABLE_DAYS='7'
# export APPFIGURES_CHART_DAYS='14'

# Optional: label shown on the table widget
# export APPFIGURES_TABLE_LABEL='App Store Sales'

# Optional: path to terminal-widget if not on PATH
# export TERMINAL_WIDGET='/opt/homebrew/bin/terminal-widget'

# Optional: force a fresh API fetch (normally caches once per day)
# export APPFIGURES_FORCE_REFRESH='1'

Lock down permissions:

chmod 600 ~/.config/terminal-widget/dashboard.env

Alternate config path: set DASHBOARD_ENV=/path/to/your.env before running the script.

3. Add widget targets in Terminal Widget

Create three targets (names must match your env vars, or use the defaults below):

Target ID Type Purpose
appfigures-table Table Last N days: Date, Downloads, Revenue
appfigures-downloads Chart (sparkline) Download trend
appfigures-revenue Chart (sparkline) Revenue trend ($)

4. Install the script

Save the script below as something like ~/bin/appfigures-widgets.sh, then:

chmod +x ~/bin/appfigures-widgets.sh

Run once manually to verify:

~/bin/appfigures-widgets.sh

Cache files go to ~/.cache/terminal-widget/ (override with WIDGET_CACHE_DIR).

5. Schedule daily updates

AppFigures data usually lags by a day; the script ends on yesterday and refetches if yesterday still shows zeros. Running once in the morning is enough.

cron example (8:00 AM daily):

0 8 * * * /Users/you/bin/appfigures-widgets.sh >>/tmp/appfigures-widgets.log 2>&1

launchd is also fine if you prefer macOS-native scheduling.

Environment variables reference

Variable Required Default Description
APPFIGURES_ACCESS_TOKEN Yes Personal Access Token from AppFigures (pat_...)
APPFIGURES_PRODUCT_IDS No all products Comma-separated numeric product IDs
APPFIGURES_TABLE_TARGET No appfigures-table Terminal Widget target for the table
APPFIGURES_DL_CHART_TARGET No appfigures-downloads Target for download sparkline
APPFIGURES_REV_CHART_TARGET No appfigures-revenue Target for revenue sparkline
APPFIGURES_TABLE_DAYS No 7 Rows in the table
APPFIGURES_CHART_DAYS No 14 Days in each sparkline
APPFIGURES_TABLE_LABEL No App Store Sales Title on the table widget
APPFIGURES_FORCE_REFRESH No 0 Set to 1 to bypass daily cache
DASHBOARD_ENV No ~/.config/terminal-widget/dashboard.env Path to config file
WIDGET_CACHE_DIR No ~/.cache/terminal-widget Cache directory
TERMINAL_WIDGET No terminal-widget Path to the CLI

Troubleshooting

Symptom Likely cause
APPFIGURES_ACCESS_TOKEN not set Missing or wrong path to dashboard.env
API request failed Invalid/expired token, wrong scope, or network issue
unexpected API response Inspect ~/.cache/terminal-widget/appfigures-sales.json; AppFigures may have changed response shape
Yesterday shows 0 / $0 Normal early in the day; script refetches until yesterday has data
Wrong apps in totals Set APPFIGURES_PRODUCT_IDS to your product ID(s)

Script (appfigures-widgets.sh)

#!/usr/bin/env bash
# AppFigures daily downloads and revenue → Terminal Widget.
# Run once per day (cron/launchd). Requires dashboard.env (see recipe instructions).
set -euo pipefail

# --- Config ---
DASHBOARD_ENV="${DASHBOARD_ENV:-$HOME/.config/terminal-widget/dashboard.env}"
if [[ -f "$DASHBOARD_ENV" ]]; then
	# shellcheck source=/dev/null
	source "$DASHBOARD_ENV"
fi

API_BASE="${APPFIGURES_API_BASE:-https://api.appfigures.com/v2}"
TOKEN="${APPFIGURES_ACCESS_TOKEN:-${APPFIGURES_TOKEN:-}}"
CACHE_DIR="${WIDGET_CACHE_DIR:-$HOME/.cache/terminal-widget}"
CACHE_JSON="$CACHE_DIR/appfigures-sales.json"
CACHE_STAMP="$CACHE_DIR/appfigures-sales.fetched_on"
daily_tsv="$CACHE_DIR/appfigures-daily.tsv"
TABLE_TARGET="${APPFIGURES_TABLE_TARGET:-appfigures-table}"
DL_CHART_TARGET="${APPFIGURES_DL_CHART_TARGET:-appfigures-downloads}"
REV_CHART_TARGET="${APPFIGURES_REV_CHART_TARGET:-appfigures-revenue}"
TABLE_LABEL="${APPFIGURES_TABLE_LABEL:-App Store Sales}"
TW="${TERMINAL_WIDGET:-terminal-widget}"
DAYS_TABLE="${APPFIGURES_TABLE_DAYS:-7}"
DAYS_CHART="${APPFIGURES_CHART_DAYS:-14}"

mkdir -p "$CACHE_DIR"

# --- Helpers ---
format_usd() {
	local amount="${1:-0}"
	printf '$%s' "$(
		awk -v n="$amount" 'BEGIN {
			neg = (n < 0)
			if (neg) n = -n
			s = sprintf("%.0f", n + 0)
			out = ""
			len = length(s)
			for (i = len; i >= 1; i--) {
				out = substr(s, i, 1) out
				if ((len - i + 1) % 3 == 0 && i > 1) out = "," out
			}
			if (neg) out = "-" out
			print out
		}'
	)"
}

format_amount() {
	local s
	s="$(format_usd "$1")"
	echo "${s#\$}"
}

widget_error() {
	local target="$1"
	local msg="$2"
	"$TW" --target "$target" \
		--icon exclamationmark.triangle.fill \
		--background "#422006" \
		--foreground "#fde68a" \
		--text "AppFigures: $msg" || true
}

if [[ -z "$TOKEN" ]]; then
	widget_error "$TABLE_TARGET" "APPFIGURES_ACCESS_TOKEN not set"
	exit 1
fi

for cmd in jq curl; do
	if ! command -v "$cmd" >/dev/null 2>&1; then
		widget_error "$TABLE_TARGET" "$cmd required"
		exit 1
	fi
done

if ! command -v "$TW" >/dev/null 2>&1; then
	widget_error "$TABLE_TARGET" "terminal-widget not found"
	exit 1
fi

date_offset_days() {
	local days="$1"
	if date -v-"${days}"d +%Y-%m-%d >/dev/null 2>&1; then
		date -v-"${days}"d +%Y-%m-%d
	elif date -d "${days} days ago" +%Y-%m-%d >/dev/null 2>&1; then
		date -d "${days} days ago" +%Y-%m-%d
	else
		widget_error "$TABLE_TARGET" "date command unsupported"
		exit 1
	fi
}

today="$(date +%Y-%m-%d)"
yesterday="$(date_offset_days 1)"
end_date="$yesterday"

fetch_days=$((DAYS_CHART > DAYS_TABLE ? DAYS_CHART : DAYS_TABLE))
if ((fetch_days > 30)); then
	fetch_days=30
fi
start_date="$(date_offset_days "$fetch_days")"

day_row_is_empty() {
	local row="$1"
	local dl rev rev_int
	[[ -z "$row" ]] && return 0
	IFS=$'\t' read -r _ dl rev <<<"$row"
	rev_int="$(printf '%.0f' "$rev" 2>/dev/null || echo 0)"
	[[ "${dl%.*}" == "0" && "$rev_int" == "0" ]]
}

cached_yesterday_incomplete() {
	local row
	[[ ! -f "$daily_tsv" ]] && return 0
	row="$(grep "^${yesterday}"$'\t' "$daily_tsv" 2>/dev/null | tail -n 1 || true)"
	day_row_is_empty "$row"
}

should_fetch=1
if [[ -f "$CACHE_STAMP" && -f "$CACHE_JSON" && "${APPFIGURES_FORCE_REFRESH:-0}" != "1" ]]; then
	stamp="$(<"$CACHE_STAMP")"
	if [[ "$stamp" == "$today" ]] && ! cached_yesterday_incomplete; then
		should_fetch=0
	fi
fi

if ((should_fetch)); then
	query="start_date=${start_date}&end_date=${end_date}&granularity=daily&group_by=date"
	if [[ -n "${APPFIGURES_PRODUCT_IDS:-}" ]]; then
		query="${query}&products=${APPFIGURES_PRODUCT_IDS}"
	fi
	url="${API_BASE}/reports/sales?${query}"

	if ! json="$(curl -fsS -H "Authorization: Bearer ${TOKEN}" -H "Accept: application/json" "$url")"; then
		widget_error "$TABLE_TARGET" "API request failed"
		exit 1
	fi
	echo "$json" >"$CACHE_JSON"
	echo "$today" >"$CACHE_STAMP"
else
	json="$(<"$CACHE_JSON")"
fi

jq -r '
  def row(d; o):
    [d, (o.downloads // o.net_downloads // o.app_downloads // 0), (o.revenue // 0)];

  def emit(d; o):
    row(d; o) | @tsv;

  if type == "array" then
    .[] | select(.date != null) | emit(.date; .)
  elif type == "object" and ([.[] | type] | all(. == "object")) and ([keys[]][0] | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}")) then
    to_entries[] | emit(.key; .value)
  elif .dates and (.dates | type) == "object" then
    .dates | to_entries[] | emit(.key; .value)
  elif .sales and (.sales | type) == "array" then
    .sales[] | select(.date != null) | emit(.date; .)
  elif type == "object" then
    [to_entries[] | .value | to_entries[] | {date: .key, data: .value}]
    | group_by(.date)
    | .[]
    | {
        date: .[0].date,
        downloads: (map(.data.downloads // .data.net_downloads // .data.app_downloads // 0) | add),
        revenue: (map(.data.revenue // 0) | add)
      }
    | emit(.date; .)
  else
    empty
  end
' <<<"$json" | sort -u >"$daily_tsv"

if [[ ! -s "$daily_tsv" ]]; then
	widget_error "$TABLE_TARGET" "unexpected API response (see cache JSON)"
	exit 1
fi

display_tsv="$daily_tsv"
if [[ "$(tail -n 1 "$daily_tsv" | cut -f1)" == "$today" ]]; then
	display_tsv="$CACHE_DIR/appfigures-daily-display.tsv"
	grep -v "^${today}"$'\t' "$daily_tsv" >"$display_tsv"
fi

table_csv="$CACHE_DIR/appfigures-table.csv"
{
	echo 'Date,Downloads,Revenue'
	tail -n "$DAYS_TABLE" "$display_tsv" | while IFS=$'\t' read -r d dl rev; do
		rev_fmt="$(format_amount "$rev" 2>/dev/null || echo "$rev")"
		printf '%s,%s,%s\n' "$d" "$dl" "$rev_fmt"
	done
} >"$table_csv"

cat "$table_csv" | "$TW" \
	--target "$TABLE_TARGET" \
	--table - \
	--grid zebra-row \
	--table-layout auto \
	--mode dark \
	--text "$TABLE_LABEL" \
	--background "#1e1b4b" \
	--foreground "#e0e7ff"

chart_slice() {
	local field="$1"
	tail -n "$DAYS_CHART" "$display_tsv" | while IFS=$'\t' read -r _ dl rev; do
		if [[ "$field" == "downloads" ]]; then
			echo "$dl"
		else
			printf '%.0f\n' "$rev"
		fi
	done | paste -sd ' ' -
}

dl_values="$(chart_slice downloads)"
rev_values="$(chart_slice revenue)"
start_chart="$(head -n 1 "$display_tsv" | cut -f1)"
end_chart="$(tail -n 1 "$display_tsv" | cut -f1)"

if [[ -n "$dl_values" ]]; then
	"$TW" \
		--target "$DL_CHART_TARGET" \
		--chart "$dl_values" \
		--chart-format sparkline \
		--base-zero \
		--annotate \
		--text "Downloads (${DAYS_CHART}d)" \
		--caption \
		--caption-left "$start_chart" \
		--caption-right "$end_chart" \
		--background "#1e1b4b" \
		--foreground "#a78bfa" \
		--text-color "#e0e7ff" \
		--caption-color "#a5b4fc"
fi

if [[ -n "$rev_values" ]]; then
	"$TW" \
		--target "$REV_CHART_TARGET" \
		--chart "$rev_values" \
		--chart-format sparkline \
		--base-zero \
		--annotate \
		--text "Revenue \$ (${DAYS_CHART}d)" \
		--caption \
		--caption-left "$start_chart" \
		--caption-right "$end_chart" \
		--background "#1e1b4b" \
		--foreground "#34d399" \
		--text-color "#e0e7ff" \
		--caption-color "#a5b4fc"
fi

Download script

Running in the background with launchd

Suggested interval: 1 day (StartInterval = 86400 seconds).

Save the script from this recipe to ~/bin/appfigures-sales-dashboard.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.appfigures-sales-dashboard</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>~/bin/appfigures-sales-dashboard.sh</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>StartInterval</key>
  <integer>86400</integer>
  <key>StandardOutPath</key>
  <string>/tmp/com.terminalwidget.appfigures-sales-dashboard.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/com.terminalwidget.appfigures-sales-dashboard.err</string>
</dict>
</plist>

From Terminal:

mkdir -p ~/bin ~/Library/LaunchAgents
# Save your script to ~/bin/appfigures-sales-dashboard.sh
cat > ~/Library/LaunchAgents/com.terminalwidget.appfigures-sales-dashboard.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.appfigures-sales-dashboard</string>
  <key>ProgramArguments</key>
  <array>
    <string>/bin/bash</string>
    <string>~/bin/appfigures-sales-dashboard.sh</string>
  </array>
  <key>RunAtLoad</key>
  <true/>
  <key>StartInterval</key>
  <integer>86400</integer>
  <key>StandardOutPath</key>
  <string>/tmp/com.terminalwidget.appfigures-sales-dashboard.log</string>
  <key>StandardErrorPath</key>
  <string>/tmp/com.terminalwidget.appfigures-sales-dashboard.err</string>
</dict>
</plist>
EOF
launchctl bootstrap "gui/$(id -u)" ~/Library/LaunchAgents/com.terminalwidget.appfigures-sales-dashboard.plist

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

← All recipes