← Home

Self-slack-shaming

2026-02-01 · data visualization

GoT Slack Shame

I try to leverage power of shame to improve myself. I believe this is a healthy and balanced way to approach better habits /s.

In practice, this means I have various indicators and mini-viz-thingamabobs on my laptop & phone reminding me to curb my appetite for bad digital habits. Last September I set up one for Slack.

Slack is one of those necessary evils nowadays. Given I (try to) abstain from social media at large, my monkey brain sees Slack as a great substitute source for similar dopamine hits. Furthermore, I have moderate obsessive compulsions to stay in the loop on every subproject at work (even ones that don’t directly concern me) Keeping Slack (and other work stuff) off my phone is easy enough but on laptop, the keyboard shortcut and applet with a red/blue circle are too much of a temptation. To try and better myself i.e. reduce Slack-lurking, I set up a logger, dwm status block and gnuplot for the logged data.

Detecting shame

I almost always forget to completely close Slack so it is usually running in the background. Consequently, we can’t just ps aux & grep it to measure usage time. Here, I made the logging script use xdotool to see what window is in focus. There is some additional logic to check the laptop is not locked or anything.

#!/bin/bash

# Directory and log setup
LOGDIR="$HOME/.local/share/slack-usage"
mkdir -p "$LOGDIR"
LOGFILE="$LOGDIR/$(date '+%Y-%m-%d').log"

# If the log exists, read the current value; else start at 0
if [ -f "$LOGFILE" ]; then
    SECONDS_SLACK=$(<"$LOGFILE")
else
    SECONDS_SLACK=0
fi

INTERVAL=10  # seconds between checks
IDLE_THRESHOLD=$((3 * 60 * 1000)) # 3 minutes in ms

while true; do
    # Detect idle time using xprintidle (outputs ms since last input)
    IDLE_MS=$(xprintidle 2>/dev/null || echo 0)

    # Detect if screen is locked (common with gnome-screensaver or similar)
    SCREEN_LOCKED=false
    if loginctl show-session "$XDG_SESSION_ID" -p LockedHint 2>/dev/null | grep -q "yes"; then
        SCREEN_LOCKED=true
    fi

    # Only count if not locked and user is active
    if [ "$SCREEN_LOCKED" = false ] && [ "$IDLE_MS" -lt "$IDLE_THRESHOLD" ]; then
        WIN_ID=$(xdotool getwindowfocus 2>/dev/null)
        if [ -n "$WIN_ID" ]; then
            WIN_CLASS=$(xprop -id "$WIN_ID" WM_CLASS 2>/dev/null | awk -F '"' '{print $4}')
            if [[ "$WIN_CLASS" == "Slack" ]]; then
                SECONDS_SLACK=$((SECONDS_SLACK + INTERVAL))
            fi
        fi
    fi

    # Overwrite the log so it's always up to date
    echo "$SECONDS_SLACK" > "$LOGFILE"

    sleep "$INTERVAL"
done

Showing the shame

The indicator is a dwm block script. I use dwmblocks (originally based on this IIRC) so i can also change the color of the indicator when it exceeds a threshold (60 min for me). It is right next to a task-block with taskwarrior task so I can glance directly to see what I am supposed to be doing.

#!/bin/sh

slack_time=$(awk '{print int($1/60 + 0.5)}' ~/.local/share/slack-usage/$(date '+%Y-%m-%d').log)

if [ "$slack_time" -gt 60 ]; then
    # ^b#840e30^ sets red background, ^d^ resets it
    echo -ne "^b#840e30^  $slack_time ^d^"
else
    echo -ne "  $slack_time"
fi

Apropos, the task block:

#! /bin/bash

# Shows tasks completed / planned for this week
truncate_string() {
    if [ ${#1} -gt 40 ]; then
        echo "${1:0:37}..."
    else
        echo "$1"
    fi
}


n_overdue_tasks=$(task +OVERDUE export | jq 'length')
n_tasks_due=$(task status:pending due.before:sunday count)
n_tasks_done=$(task status:completed end.after:sunday-7d count)


if [ "$n_overdue_tasks" -gt 0 ]; then
    task_numbers=$(printf "%s/%s" "$n_tasks_done" "$(($n_tasks_due+$n_tasks_done))" )
else
    task_numbers=$(printf "%s/%s" "$n_tasks_done" "$(($n_tasks_due+$n_tasks_done))" )
fi

n_active_tasks="$(task +ACTIVE export | jq 'length')"
highest_prio_task="$(task status:pending export | jq -r 'max_by(.urgency) | "\(.project):\(.description)"')"

if [ "$n_active_tasks" -ne 0 ]; then
    top_task="$(task +ACTIVE export | jq -r 'max_by(.urgency) | "\(.project):\(.description)"')"
else
    top_task=$highest_prio_task
fi

truncated_task=$(truncate_string "$top_task")
echo -ne "^b#282c34^$truncated_task$task_numbers^d^"

looks something like this (with 4 minutes on the clock) dwm-block

Plotting all the shame

So after running this a while, we have a folder full of log files. To plot those, I use gnuplot. By default it plots the current week, with -a you get everything.

#!/bin/bash

LOGDIR="$HOME/.local/share/slack-usage"
DATAFILE=$(mktemp)
TICSFILE=$(mktemp)
TICS2FILE=$(mktemp)

# Detect OS type for date command
if date --version >/dev/null 2>&1; then
    GNU_DATE=true
else
    GNU_DATE=false
fi

# Check for --all / -a argument
PLOT_ALL=false
if [[ "$1" == "--all" || "$1" == "-a" ]]; then
    PLOT_ALL=true
fi

# Dynamic font sizing and offsets
if $PLOT_ALL; then
    XTICS_FONT=",8"
    YLABEL_FONT=",12"
    XTICS_OFFSET="-2.0,-2.5"
    BMARGIN=5
else
    XTICS_FONT=",14"
    YLABEL_FONT=",14"
    XTICS_OFFSET="-6.0,-3.0"
    BMARGIN=5
fi

# Helper function to get day of week (1=Monday, 7=Sunday)
get_dow() {
    if $GNU_DATE; then
        date -d "$1" "+%u"
    else
        date -j -f "%Y-%m-%d" "$1" "+%u"
    fi
}

# Helper function to get dd.MM format
get_ddmm() {
    if $GNU_DATE; then
        date -d "$1" "+%d.%m"
    else
        date -j -f "%Y-%m-%d" "$1" "+%d.%m"
    fi
}

# Helper function to get dd.MM.yy format
get_ddmmyy() {
    if $GNU_DATE; then
        date -d "$1" "+%d.%m.%y"
    else
        date -j -f "%Y-%m-%d" "$1" "+%d.%m.%y"
    fi
}

# Helper function to get ISO week number
get_weeknum() {
    if $GNU_DATE; then
        date -d "$1" "+%V"
    else
        date -j -f "%Y-%m-%d" "$1" "+%V"
    fi
}

# Helper function to get abbreviated month + year (e.g. "Jan 25")
get_mon_year() {
    if $GNU_DATE; then
        date -d "$1" "+%b %y"
    else
        date -j -f "%Y-%m-%d" "$1" "+%b %y"
    fi
}

# Helper function to add days
add_days() {
    local date_str="$1"
    local days="$2"
    if $GNU_DATE; then
        date -d "$date_str +$days days" "+%Y-%m-%d"
    else
        date -j -v+"${days}"d -f "%Y-%m-%d" "$date_str" "+%Y-%m-%d"
    fi
}

# Determine date range
if $PLOT_ALL; then
    START_DATE=$(ls "$LOGDIR"/*.log 2>/dev/null | sort | head -n1 | xargs -n1 basename | sed 's/\.log$//')
    END_DATE=$(ls "$LOGDIR"/*.log 2>/dev/null | sort | tail -n1 | xargs -n1 basename | sed 's/\.log$//')
else
    if [[ $(get_dow $(date "+%Y-%m-%d")) -eq 1 ]]; then
        MON=$(date "+%Y-%m-%d")
    else
        if $GNU_DATE; then
            MON=$(date -d "last monday" "+%Y-%m-%d")
        else
            MON=$(date -v-mon "+%Y-%m-%d")
        fi
    fi
    if $GNU_DATE; then
        SUN=$(date -d "next sunday" "+%Y-%m-%d")
    else
        SUN=$(date -v+sun "+%Y-%m-%d")
    fi
    START_DATE="$MON"
    END_DATE="$SUN"
fi

CURRENT_DATE="$START_DATE"
DAY_INDEX=0

if $PLOT_ALL; then
    # Collect all daily data and track week boundaries
    declare -a all_dates
    declare -A day_values
    
    while [[ "$CURRENT_DATE" < "$END_DATE" || "$CURRENT_DATE" == "$END_DATE" ]]; do
        FILE="$LOGDIR/$CURRENT_DATE.log"
        if [[ -f "$FILE" ]]; then
            s=$(cat "$FILE")
        else
            s=0
        fi
        m=$((s/60))
        
        all_dates+=("$CURRENT_DATE")
        day_values["$CURRENT_DATE"]=$m
        
        CURRENT_DATE=$(add_days "$CURRENT_DATE" 1)
    done
    
    # Output daily data (bars) - just index and value, no xtic labels
    for i in "${!all_dates[@]}"; do
        date="${all_dates[$i]}"
        echo "$i ${day_values["$date"]}"
    done > "$DATAFILE"
    
    # Compute average ± stddev of non-zero days
    STATS=$(awk '
    $2 > 0 { sum += $2; sumsq += $2*$2; n++ }
    END {
        if (n > 0) {
            avg = sum / n;
            var = sumsq / n - avg * avg;
            if (var < 0) var = 0;
            sd = sqrt(var);
            printf "μ=%.0f σ=%.0f", avg, sd
        } else { printf "μ=0 σ=0" }
    }' "$DATAFILE")
    TITLE="  usage in minutes (${STATS})"
    
    # Generate week number labels (row 1) and month/year labels (row 2)
    last_index=$((${#all_dates[@]} - 1))
    prev_mon_year=""
    
    for i in "${!all_dates[@]}"; do
        date="${all_dates[$i]}"
        dow=$(get_dow "$date")
        
        # Week number tick at center of each week (Monday found, center = +3)
        if [[ "$dow" -eq 1 ]]; then
            center_idx=$((i + 3))
            if [[ $center_idx -gt $last_index ]]; then
                center_idx=$last_index
            fi
            
            weeknum=$(get_weeknum "$date")
            echo "set xtics add (\"$weeknum\" $center_idx)"
        fi
        
        # Month/year label: place at first occurrence of each new month
        mon_year=$(get_mon_year "$date")
        if [[ "$mon_year" != "$prev_mon_year" ]]; then
            # Find end of this month to center the label
            month_start_idx=$i
            month_end_idx=$i
            for j in $(seq $((i+1)) $last_index); do
                j_mon_year=$(get_mon_year "${all_dates[$j]}")
                if [[ "$j_mon_year" == "$mon_year" ]]; then
                    month_end_idx=$j
                else
                    break
                fi
            done
            month_center_idx=$(( (month_start_idx + month_end_idx) / 2 ))
            echo "set label \"$mon_year\" at first $month_center_idx, character 2.6 center font \",9\""
            prev_mon_year="$mon_year"
        fi
    done > "$TICSFILE"
    
    # Split into separate files
    grep "^set xtics" "$TICSFILE" > "${TICSFILE}.x"
    grep "^set label" "$TICSFILE" > "${TICS2FILE}"
    mv "${TICSFILE}.x" "$TICSFILE"
    
else
    # Daily view for week mode
    while [[ "$CURRENT_DATE" < "$END_DATE" || "$CURRENT_DATE" == "$END_DATE" ]]; do
        FILE="$LOGDIR/$CURRENT_DATE.log"
        if [[ -f "$FILE" ]]; then
            s=$(cat "$FILE")
        else
            s=0
        fi
        m=$((s/60))

        # Convert to weekday + dd.MM
        if $GNU_DATE; then
            label=$(date -d "$CURRENT_DATE" "+%a %d.%m")
            CURRENT_DATE=$(date -I -d "$CURRENT_DATE +1 day")
        else
            label=$(date -j -f "%Y-%m-%d" "$CURRENT_DATE" "+%a %d.%m")
            CURRENT_DATE=$(date -j -v+1d -f "%Y-%m-%d" "$CURRENT_DATE" "+%Y-%m-%d")
        fi

        # Quote the label
        echo "\"$label\" $m"
    done > "$DATAFILE"
    
    TITLE="  usage in minutes"
fi

if $PLOT_ALL; then
    NUM_DAYS=${#all_dates[@]}
    gnuplot -persist <<EOF
set terminal wxt noraise size 1200,600
set title "$TITLE" font ",16"
set style fill solid 0.8
set boxwidth 0.9
set ylabel "Minutes" offset 1,0 font "$YLABEL_FONT"
set xrange [-1:$NUM_DAYS]
set xtics () font ",8" nomirror
set bmargin $BMARGIN
set nokey
set grid ytics lt 0 lc rgb '#000000'
$(cat "$TICSFILE")
$(cat "$TICS2FILE")
plot "$DATAFILE" using 1:2 with boxes notitle
EOF
else
    gnuplot -persist <<EOF
set terminal wxt noraise size 1200,600
set title "$TITLE" font ",16"
set style data histograms
set style fill solid 0.8
set boxwidth 0.9
set ylabel "Minutes" offset 1,0 font "$YLABEL_FONT"
set xtics rotate by 45 offset $XTICS_OFFSET font "$XTICS_FONT"
set bmargin $BMARGIN
set nokey
set grid ytics lt 0 lc rgb '#000000'
plot "$DATAFILE" using 2:xtic(1) notitle
EOF
fi
rm "$DATAFILE" "$TICSFILE" "$TICS2FILE"

End result is something like this (weekly view above and the all below). Month and day in Finnish (follow LOCALE).

Results

While I don’t have the data from before I started using this self-shaming technique, it seems I am at around a 77 minutes per workday average with rather substantial swings. Given that is actual time spent on Slack (not just uptime) it seems excessive. Or maybe intuitively, 1 h / workday oughta be sufficient. More powerful shaming methods may be required.