Last active
March 11, 2026 16:49
-
-
Save aaronedev/4288b2bd16a86153e86e3c51431471e5 to your computer and use it in GitHub Desktop.
time calc script (days, hours, years support)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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