#!/bin/sh
# simplistic pain list printer
# pesco 2016-2019, isc license
#
# reads issue items on stdin, one per line, not indented. items are arbitrary
# text, rated by adding ">AxBxC<" anywhere on the line, where A,B,C,... are
# rating numbers in arbitrary categories. any number of categories can be used.
#
# if a pattern argument is given, it is interpreted as an extended regular
# expression to filter for. the -v option inverts the filter.
# 
# empty lines and those containing only a shell-style comment (#) are ignored.
# in-line comments are not recognized; this allows the use of hash-tags.
#
# the rating scale is always 1-9; 9 should always be used for the highest level.
# coarser scales should be mapped to 1-9 by ignoring some levels; for instance,
# use 1,3,5,7,9 for a 5-level scale. it is recommended to document a clear
# meaning for each level to make rating easy and unambiguous.
#
# an item's pain value is the geometric mean of its ratings.
#
# the output sorts items by pain value. a solid line visualizes a configurable
# threshold (default -t 6). a dotted line shows the weighted median (half of
# total pain). higher-value items are shown in a brighter shade.
#
#
# == EXTRA FEATURES ==
#
# it is suggested to use the following features only in specific circumstances
# as indicated.
#
# - text in square brackets [...] at the beginning of a line is highlighted in
#   the output. this can be used for example to mark items with a planned time.
#
# - items can be marked for urgency after time T by adding a tag of the form "!T"
#   where T is given in (fractional) megaseconds after the Unix epoch. yes, it is
#   awkward, but also simple. how to convert using GNU date:
#
#       $ when() { echo $(($(date +%s -d "$1") / 1e6)); }
#       $ when tomorrow
#       1459.43703
#
#   (hint: round to the first decimal, a day is 86.4ks)
#
#   urgent items are displayed in red. use this feature to flag items before it
#   becomes too late to act on them.
#
# - items marked with a tag of the form "^T" will be ignored until time T. use
#   this for items that cannot or should not be acted on before the given time.
#
# - indented (non-comment) lines below an item are interpreted as items pending
#   completion of their parent. for each child, a green plus sign is appended
#   to the parent item. pending items are not shown themselves but count toward
#   the pain value of their parent. use this to account for but otherwise hide
#   items that strictly depend on the completion of another.


theta=6
nocolor=1
maxitems=0  # infinite

# awk program for formatted output
format='
    function color(c) {
        return nocolor? "" : sprintf("\033[3%dm", c)
    }

    function rgbcolor(r,g,b) {
        return nocolor? "" : sprintf("\033[38;2;%d;%d;%dm", r,g,b)
    }

    function grey(l) {
        return rgbcolor(l,l,l)
    }

    function reset() {
        return nocolor? "" : "\033[m"
    }

    function bold() {
        return nocolor? "" : "\033[1m"
    }

    {
        p=0
        if(match($0, /\++$/)) { p=RLENGTH }
        if(substr($0,7,1) == "^") { npost++; npost+=p; next }
        nplus += p
        n++

        urgent[n] = (substr($0,7,1) == "!")
        if(urgent[n]) { nurg++ }

        pain[n] = $1
        if($1 >= theta) { nhi++ }
        total += $1

        if($1 == 0) {
            sub(/^...../, "     ")
            nun++
        }

        # shorten -tag: fields; and do it without submatch references
        # i lov^Whate awk :v
        gsub(/[[:space:]]-[[:alnum:]]+:/, "&<-XXX->&")
        gsub(/:<-XXX->[[:space:]]-[[:alnum:]]+:[^[:space:]]*/, "...")

        item[n] = $0
    }

    END {
        red = 1
        yellow = 3
        green = 2
        white = 7

        if(maxitems && n > maxitems) { n=maxitems }
        for(i=1; i<=n; i++) {
            it = item[i]
            if(urgent[i]) {
                it = bold() color(red) it reset()
            } else {
                if(pain[i] == 0) {
                     base = rgbcolor(235, 167, 231)    # light orchid, #EBA7E7
                } else if(pain[i] > 9) {
                     base = color(white)
                } else {
                     base = grey(32+(pain[i]-1)*28)
                }
                it = base it
                sub(/[! ] \[[^\]]*\]/, bold() color(yellow) "&" reset() base, it)
                sub(/\++$/, color(green) "&", it)
            }
            print it

            if(i == nhi) {
                print color(red), "____________________________________" \
                                  "_____________________________________"
            }

            acc += pain[i]
            if(!half && acc >= total/2) {
                print color(yellow), "...................................." \
                                     "....................................."
                half = 1
            }
        }

        if(NR > 0) {
            print reset() bold() # adds blank line
            printf("%6.1f pain  \t%d active items", total, NR)
            if(nun)   printf(", %d unrated", nun)
            if(nurg)  printf(", %d urgent (%s!%s)", nurg,
                             color(red), reset() bold())
            if(nplus) printf("  \t%d pending (%s+%s)", nplus,
                             color(green), reset() bold())
            if(npost) printf(", %d postponed", npost)
            print reset()
        }
    }
'

# awk program to summarize totals
summarize='
    function context(s, arr) {
        delete arr
        while(match(s, /[[:space:]][#@][a-zA-Z0-9]+/)) {
            t = substr(s, RSTART+2, RLENGTH-2)
            s = substr(s, RSTART+RLENGTH)
            arr[t] = 1;
        }
    }

    {
        if(substr($0,7,1) == "^") { next }
        total += $1

        context($0, ctx)
        for(t in ctx) { sum[t] += $1 }
    }

    END {
        printf("%6.1f total", total)
        for(t in sum)
                printf("  %.1f %s", sum[t], t)
        print ""
    }
'

if test -t 1  # is stdout a tty?
then
    nocolor=0
    maxitems=$(($(tput li) - 5))
fi

args=`getopt npsvt:l: $*`
if [ $? -ne 0 ]; then
    echo "usage: $0 [-npsv] [-t THRESHOLD] [-l MAX] [pattern]"
    exit 1
fi
set -- $args
while [ $# -ne 0 ]; do
    case "$1" in
        -n) nocolor=1; shift;;
        -p) format='{print}'; shift;;
        -s) format="$summarize"; shift;;
        -t) theta="$2"; shift; shift;;
        -v) invert=1; shift;;
        -l) maxitems="$2"; shift; shift;;
        --) shift; break;;
    esac
done

# collect items and attach pain values
awk '   
    function product(xs) {
        p = 1
        for(i in xs) { p *= xs[i] }
        return p
    }

    function score(s) {
        if(match(s, />[1-9](x[1-9])*</)) {
            rating = substr(s, RSTART+1, RLENGTH-2)
            split(rating, xs, /x/)

            return product(xs) ^ (1 / length(xs))
        }
        return 0
    }

    function printit() {
        #if(item && (up || urg)) {
        if(item) {
            if(plus) plus = " " plus
            printf("%5.1f %s %s%s\n", pain, urg?"!":up?" ":"^", item, plus)
        }
        item = ""
    }

    BEGIN { "date +%s" | getline now }

    # skip comments and blank lines
    /^[[:space:]]*(#|$)/ { next }

    # filter by pattern argument
    {
        if(xor(!match($0, pattern), invert)) next
    }

    # count pending (indented) items
    /^[[:space:]]+[^[:space:]#]/ {
        plus = plus "+"
        pain = pain + score($0)
        next
    }

    # process a new item
    {
        printit()   # print previous item

        item = $0
        plus = ""
        pain = score($0)
        urg = 0
        up = 1

        if(match($0, / ![0-9]*(\.[0-9]+)?( |$)/)) {
            urgtime = substr($0, RSTART+2, RLENGTH-2)
            urg = (now >= urgtime * 1e6)
        }

        if(match($0, / \^[0-9]+(\.[0-9]+)?/)) {
            uptime = substr($0, RSTART+2, RLENGTH-2)
            up = (now >= uptime * 1e6)
        }
    }

    END { printit() }
' pattern="$*" invert=$invert |
# sort by pain
sort -rsn -k 1 |
# format output
awk "$format" theta=$theta nocolor=$nocolor maxitems=$maxitems
