Skip to content

Instantly share code, notes, and snippets.

@aaronedev
Last active March 11, 2026 16:49
Show Gist options
  • Select an option

  • Save aaronedev/4288b2bd16a86153e86e3c51431471e5 to your computer and use it in GitHub Desktop.

Select an option

Save aaronedev/4288b2bd16a86153e86e3c51431471e5 to your computer and use it in GitHub Desktop.
time calc script (days, hours, years support)
#!/usr/bin/env bash
set -euo pipefail
##############################################################################
### tcalc — quick date/time calculations (qalc wrapper)
### Author: aaronedev
### https://github.com/aaronedev
##############################################################################
# tcalc — fast date/time math wrapper around qalc, with:
# - short units (e.g. 10m -> 10min)
# - colored + structured output
# - auto multi-format date output (ISO / DE / US) when result is a date/time
PROG="$(basename "$0")"
TZ_SUFFIX=""
ENABLE_TIME_ALIASES=1
# output control
FMT="auto" # auto | pretty | raw | iso | iso-utc | de | us | us24 | epoch
COLOR_MODE="auto" # auto | always | never
die() {
printf '%s: %s\n' "$PROG" "$*" >&2
exit 1
}
usage() {
cat <<'EOF'
tcalc — quick date/time calculations (qalc wrapper)
Usage:
tcalc [opts] <qalc expression...>
tcalc [opts] add <date/time> <delta...>
tcalc [opts] sub <date/time> <delta...>
tcalc days <date1> <date2> [basis]
tcalc epoch [date/time]
tcalc fromepoch <seconds>
tcalc until <date/time> [unit]
tcalc diag
tcalc help
Options:
--utc | --tz Z|UTC|GMT|+HH:MM|-HH:MM Append timezone suffix to *input* datetimes if missing
--alias | --no-alias Enable/disable unit short-hands (default: on)
--fmt auto|pretty|raw|iso|iso-utc|de|us|us24|epoch
--color auto|always|never
-h, --help
Unit short-hands (only when a NUMBER precedes the unit):
10m => 10min (minute unit is "min" in Qalculate)
2w => 2week
3mo => 3month
1y => 1year
"... to m" / "... -> m" => "... to min"
Date/time input format (recommended by Qalculate):
YYYY-MM-DDTHH:MM:SS with optional Z/UTC/GMT or +/-HH:MM suffix
HH:MM (automatically evaluates as today's date)
Copy-friendly:
--fmt iso / de / us / us24 / iso-utc / epoch prints ONLY that value.
Examples:
tcalc 'now + 90m'
tcalc '16:30 + 12h'
tcalc add '2026-01-17 10:30' '3d + 4h'
tcalc --fmt de 'now + 3d'
tcalc --fmt iso-utc add '2026-01-17 10:30' '90m'
EOF
}
need_qalc() {
command -v qalc >/dev/null 2>&1 || die "missing dependency: qalc (install: sudo pacman -S --needed libqalculate)"
}
has_tz_suffix() {
local s="$1"
[[ $s =~ (Z|UTC|GMT|[+-][0-9]{2}:[0-9]{2})$ ]]
}
trim() {
local s="$1"
s="$(printf '%s' "$s" | sed -e 's/^[[:space:]]\+//' -e 's/[[:space:]]\+$//')"
printf '%s' "$s"
}
strip_quotes() {
local s
s="$(trim "$1")"
if [[ $s =~ ^\".*\"$ ]]; then
s="${s:1:${#s}-2}"
fi
printf '%s' "$s"
}
normalize_datetime() {
local s
s="$(trim "$1")"
# if it's just a time (HH:MM or HH:MM:SS), prepend today
# pad single digit hour first
if [[ $s =~ ^[0-9]:[0-5][0-9](:[0-5][0-9])?$ ]]; then
s="0${s}"
fi
if [[ $s =~ ^(2[0-3]|[0-1][0-9]):[0-5][0-9](:[0-5][0-9])?$ ]]; then
s="$(date +%Y-%m-%d)T${s}"
fi
# allow "YYYY-MM-DD HH:MM" => "YYYY-MM-DDTHH:MM"
if [[ $s == *" "* && $s != *"T"* ]]; then
s="${s/ /T}"
fi
# if it's just a date, add midnight
if [[ $s =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
s="${s}T00:00:00"
fi
# add missing seconds for "YYYY-MM-DDTHH:MM"
if [[ $s =~ ^([0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2})(Z|UTC|GMT|[+-][0-9]{2}:[0-9]{2})?$ ]]; then
local base="${BASH_REMATCH[1]}"
local tz="${BASH_REMATCH[2]:-}"
s="${base}:00${tz}"
fi
# append tz suffix if requested and missing
if [[ -n $TZ_SUFFIX ]] && ! has_tz_suffix "$s"; then
s="${s}${TZ_SUFFIX}"
fi
printf '%s' "$s"
}
normalize_date() {
local s
s="$(trim "$1")"
s="${s%%T*}"
s="${s%% *}"
printf '%s' "$s"
}
expand_time_aliases() {
local expr="$1"
[[ $ENABLE_TIME_ALIASES -eq 1 ]] || {
printf '%s' "$expr"
return 0
}
# Minute unit is "min" in Qalculate.
printf '%s' "$expr" | sed -E \
-e 's/(^|[^[:alpha:]])(to|convert|->)([[:space:]]+)m([^[:alpha:]]|$)/\1\2\3min\4/g' \
-e 's/(^|[^[:alpha:]])(to|convert|->)([[:space:]]+)w([^[:alpha:]]|$)/\1\2\3week\4/g' \
-e 's/(^|[^[:alpha:]])(to|convert|->)([[:space:]]+)mo([^[:alpha:]]|$)/\1\2\3month\4/g' \
-e 's/(^|[^[:alpha:]])(to|convert|->)([[:space:]]+)y([^[:alpha:]]|$)/\1\2\3year\4/g' \
-e 's/(^|[^[:alpha:]])([0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?)([[:space:]]*)mo([^[:alpha:]]|$)/\1\2\5month\6/g' \
-e 's/(^|[^[:alpha:]])([0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?)([[:space:]]*)w([^[:alpha:]]|$)/\1\2\5week\6/g' \
-e 's/(^|[^[:alpha:]])([0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?)([[:space:]]*)y([^[:alpha:]]|$)/\1\2\5year\6/g' \
-e 's/(^|[^[:alpha:]])([0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?)([[:space:]]*)m([^[:alpha:]]|$)/\1\2\5min\6/g'
}
q_eval() {
need_qalc
local expr="$1"
expr="$(expand_time_aliases "$expr")"
qalc -t "$expr" # --terse reduces output to just the result
}
# --- colors / formatting ---
use_color() {
case "$COLOR_MODE" in
always) return 0 ;;
never) return 1 ;;
auto)
[[ -t 1 ]] && [[ -z ${NO_COLOR-} ]] && [[ -z ${TCALC_NO_COLOR-} ]]
;;
*) die "invalid --color: $COLOR_MODE" ;;
esac
}
init_colors() {
if use_color; then
BOLD=$'\033[1m'
DIM=$'\033[2m'
CYN=$'\033[36m'
GRN=$'\033[32m'
YLW=$'\033[33m'
RED=$'\033[31m'
RST=$'\033[0m'
else
BOLD=""
DIM=""
CYN=""
GRN=""
YLW=""
RED=""
RST=""
fi
}
kv() { # key value
local k="$1" v="$2"
printf " %s%-10s%s %s\n" "$CYN" "${k}:" "$RST" "$v"
}
looks_like_iso_date() {
local s="$1"
s="$(strip_quotes "$s")"
# Require YYYY-MM-DD prefix to avoid misclassifying plain numbers as dates
[[ $s =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2} ]]
}
to_epoch() {
local s="$1"
s="$(strip_quotes "$s")"
# normalize unicode minus just in case
s="$(printf '%s' "$s" | tr '−' '-')"
# GNU date parses ISO-like strings and can output epoch seconds with +%s.
date --date="$s" +%s 2>/dev/null || return 1
}
format_dt() {
local epoch="$1" fmt="$2"
date --date="@$epoch" +"$fmt"
}
render() {
local expr="$1" raw="$2"
raw="$(strip_quotes "$raw")"
# auto mode: pretty in terminal, raw otherwise
local effective_fmt="$FMT"
if [[ $effective_fmt == "auto" ]]; then
if [[ -t 1 ]]; then effective_fmt="pretty"; else effective_fmt="raw"; fi
fi
if [[ $effective_fmt == "raw" ]]; then
printf '%s\n' "$raw"
return 0
fi
# If it's a date/time, build alternate formats via GNU date format specifiers.
local is_dt=0 epoch=""
if looks_like_iso_date "$raw"; then
if epoch="$(to_epoch "$raw")"; then
is_dt=1
fi
fi
if [[ $effective_fmt != "pretty" ]]; then
[[ $is_dt -eq 1 ]] || die "--fmt $effective_fmt requested, but result is not a date/time: $raw"
case "$effective_fmt" in
iso) format_dt "$epoch" "%Y-%m-%dT%H:%M:%S%:z" ;; # %:z => +HH:MM
iso-utc) TZ=UTC format_dt "$epoch" "%Y-%m-%dT%H:%M:%SZ" ;;
de) format_dt "$epoch" "%d.%m.%Y %H:%M:%S" ;;
us) LC_ALL=C format_dt "$epoch" "%m/%d/%Y %I:%M:%S %p" ;; # %p AM/PM
us24) format_dt "$epoch" "%m/%d/%Y %H:%M:%S" ;;
epoch) printf '%s\n' "$epoch" ;;
*) die "invalid --fmt: $effective_fmt" ;;
esac
return 0
fi
init_colors
printf "%s%s%s\n" "$BOLD" "tcalc" "$RST"
kv "expr" "$expr"
if [[ $is_dt -eq 1 ]]; then
kv "raw" "$raw"
kv "epoch" "$epoch"
local iso_local iso_utc de us us24
iso_local="$(format_dt "$epoch" "%Y-%m-%dT%H:%M:%S%:z")" # ISO-ish with colon offset
iso_utc="$(TZ=UTC format_dt "$epoch" "%Y-%m-%dT%H:%M:%SZ")"
de="$(format_dt "$epoch" "%d.%m.%Y %H:%M:%S")"
us="$(LC_ALL=C format_dt "$epoch" "%m/%d/%Y %I:%M:%S %p")"
us24="$(format_dt "$epoch" "%m/%d/%Y %H:%M:%S")"
printf "%s%s%s\n" "$BOLD" "formats" "$RST"
kv "iso" "$iso_local"
kv "iso_utc" "$iso_utc"
kv "de" "$de"
kv "us" "$us"
kv "us24" "$us24"
printf "%s\n" "${DIM}Tip: copy-only output: --fmt iso|de|us|us24|iso-utc|epoch${RST}"
else
kv "result" "$raw"
fi
}
# --- parse args ---
if [[ $# -eq 0 ]]; then
usage
exit 1
fi
while [[ $# -gt 0 ]]; do
case "$1" in
--utc)
TZ_SUFFIX="Z"
shift
;;
--tz)
[[ $# -ge 2 ]] || die "missing value for --tz"
TZ_SUFFIX="$2"
shift 2
;;
--alias)
ENABLE_TIME_ALIASES=1
shift
;;
--no-alias)
ENABLE_TIME_ALIASES=0
shift
;;
--fmt)
[[ $# -ge 2 ]] || die "missing value for --fmt"
FMT="$2"
shift 2
;;
--color)
[[ $# -ge 2 ]] || die "missing value for --color"
COLOR_MODE="$2"
shift 2
;;
-h | --help | help)
usage
exit 0
;;
--)
shift
break
;;
*) break ;;
esac
done
if [[ -n $TZ_SUFFIX && ! $TZ_SUFFIX =~ ^(Z|UTC|GMT|[+-][0-9]{2}:[0-9]{2})$ ]]; then
die "invalid --tz value: $TZ_SUFFIX (use Z, UTC, GMT, or +/-HH:MM)"
fi
# --- build qalc expr by subcommand ---
cmd="${1:-}"
expr=""
case "$cmd" in
add)
[[ $# -ge 3 ]] || die "usage: $PROG [opts] add <date/time> <delta...>"
dt="$(normalize_datetime "$2")"
shift 2
delta="$*"
dt="${dt//\"/\\\"}"
expr="$(printf '"%s" + (%s)' "$dt" "$delta")"
;;
sub)
[[ $# -ge 3 ]] || die "usage: $PROG [opts] sub <date/time> <delta...>"
dt="$(normalize_datetime "$2")"
shift 2
delta="$*"
dt="${dt//\"/\\\"}"
expr="$(printf '"%s" - (%s)' "$dt" "$delta")"
;;
days)
[[ $# -ge 3 ]] || die "usage: $PROG days <date1> <date2> [basis]"
d1="$(normalize_date "$2")"
d2="$(normalize_date "$3")"
basis="${4:-}"
if [[ -n $basis ]]; then
expr="days($d1, $d2, $basis)"
else
expr="days($d1, $d2)"
fi
;;
epoch)
if [[ $# -ge 2 ]]; then
dt="$(normalize_datetime "$2")"
dt="${dt//\"/\\\"}"
expr="$(printf 'timestamp("%s")' "$dt")"
else
expr="timestamp(now)"
fi
;;
fromepoch)
[[ $# -ge 2 ]] || die "usage: $PROG fromepoch <seconds>"
expr="stamptodate($2)"
;;
until)
[[ $# -ge 2 ]] || die "usage: $PROG until <date/time> [unit]"
target="$(normalize_datetime "$2")"
unit="${3:-}"
target="${target//\"/\\\"}"
if [[ -n $unit ]]; then
expr="$(printf '(timestamp("%s") - timestamp(now)) to %s' "$target" "$unit")"
else
expr="$(printf 'timestamp("%s") - timestamp(now)' "$target")"
fi
;;
diag)
need_qalc
echo "qalc: $(command -v qalc)"
qalc -v || true
exit 0
;;
*)
expr="$*"
# Auto-expand standalone "HH:MM" or "H:MM" at start of expression to today's datetime
if [[ $expr =~ ^([0-9]:[0-5][0-9](:[0-5][0-9])?)([^0-9].*)?$ ]]; then
expr="0${expr}"
fi
if [[ $expr =~ ^((2[0-3]|[0-1][0-9]):[0-5][0-9](:[0-5][0-9])?)([^0-9].*)?$ ]]; then
expr="\"$(date +%Y-%m-%d)T${BASH_REMATCH[1]}\"${BASH_REMATCH[4]:-}"
fi
;;
esac
raw="$(q_eval "$expr")"
render "$expr" "$raw"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment