Working with Dates and Times
🕐 Intermediate Topic 5 — Working with Dates and Times
Date and time handling trips up shell scripters more than almost any other topic. The tools are powerful but the interfaces are inconsistent — GNU date and BSD date (macOS) have completely different syntax for date arithmetic. Epoch seconds are the lingua franca that makes everything else predictable. This chapter covers every form of date formatting, arithmetic entirely in shell integers, timezone handling, comparing and aging timestamps, the bash built-in time formatting that avoids forks in tight loops, and scheduling decision patterns used in real-world cron jobs and maintenance scripts.
1 — The date Command and Format Strings
date with a +FORMAT argument controls its output. Every format directive starts with %. The result is a string you compose exactly as needed — for log files, filenames, display, or further arithmetic.
| Directive | Meaning | Example output |
|---|---|---|
%Y | 4-digit year | 2026 |
%y | 2-digit year | 26 |
%m | Month (01–12) | 06 |
%d | Day of month (01–31) | 09 |
%j | Day of year (001–366) | 160 |
%H | Hour, 24h (00–23) | 14 |
%I | Hour, 12h (01–12) | 02 |
%M | Minute (00–59) | 30 |
%S | Second (00–60) | 05 |
%N | Nanoseconds (GNU only) | 123456789 |
%s | Unix epoch seconds since 1970-01-01 UTC | 1749470405 |
%A | Full weekday name | Monday |
%a | Abbreviated weekday | Mon |
%B | Full month name | June |
%b | Abbreviated month name | Jun |
%u | Day of week (1=Mon … 7=Sun) | 1 |
%w | Day of week (0=Sun … 6=Sat) | 1 |
%W | Week number of year (Mon-based) | 23 |
%Z | Timezone abbreviation | UTC |
%z | UTC offset | +0100 |
%:z | UTC offset with colon (GNU only) | +01:00 |
%F | ISO date shorthand: %Y-%m-%d | 2026-06-09 |
%T | Time shorthand: %H:%M:%S | 14:30:05 |
%R | Hour:minute: %H:%M | 14:30 |
%D | US date: %m/%d/%y | 06/09/26 |
%% | Literal percent sign | % |
# ── Formats for filenames (no spaces or colons) ───────────────
date '+%Y%m%d' → 20260609
date '+%Y%m%d_%H%M%S' → 20260609_143005
date '+%Y-%m-%dT%H%M%S' → 2026-06-09T143005
# ── ISO 8601 — the universal machine-readable format ─────────
date '+%Y-%m-%d' → 2026-06-09
date '+%Y-%m-%dT%H:%M:%S' → 2026-06-09T14:30:05
date -Is → 2026-06-09T14:30:05+01:00 (GNU shorthand)
date '+%Y-%m-%dT%H:%M:%S%z' → 2026-06-09T14:30:05+0100
# ── Human-readable log timestamps ────────────────────────────
date '+%a %b %d %T %Z %Y' → Mon Jun 09 14:30:05 UTC 2026
date '+[%Y-%m-%d %H:%M:%S]' → [2026-06-09 14:30:05]
# ── Epoch seconds — the key to all arithmetic ─────────────────
date '+%s' → 1749470405
# ── Suppress newline / assign to variable ─────────────────────
ts=$(date '+%Y%m%d_%H%M%S')
logfile="app_${ts}.log" → app_20260609_143005.log
# ── bash built-in — no fork, fast in loops ────────────────────
printf -v ts '%(%Y%m%d_%H%M%S)T' -1
# -1 = current time. -2 = time shell was invoked. Any epoch int works.
printf -v epoch '%(%s)T' -1 # epoch seconds, no fork
printf '%(%FT%T)T' built-in uses the same strftime directives as date, but is a bash built-in — no subprocess is spawned. Always prefer it inside loops where performance matters.date (Linux). macOS ships with BSD date, which has completely different syntax for date arithmetic and parsing. Key differences: BSD uses -j -f FORMAT INPUT to parse, and -v +Nd for arithmetic. If you write scripts that must run on both, the safest approach is to convert everything to epoch seconds immediately and do arithmetic purely in integers — that part is fully portable.2 — Epoch Seconds: The Foundation of Date Arithmetic
Unix epoch time is the number of seconds since 1970-01-01 00:00:00 UTC. It's a plain integer — no timezone confusion, no calendar edge cases, just a number you can add, subtract, and compare with standard shell arithmetic. Every robust date calculation should start here.
# ── Current epoch ─────────────────────────────────────────────
date '+%s' → 1749470405
printf -v now '%(%s)T' -1 # no fork
# ── Parse a date string to epoch (GNU date) ───────────────────
date -d '2026-06-09' '+%s' → 1749427200
date -d '2026-06-09 14:30:00' '+%s' → 1749479400
date -d 'yesterday' '+%s'
date -d 'next Monday' '+%s'
date -d '3 days ago' '+%s'
date -d '2026-06-09 + 7 days' '+%s'
# ── Parse from a file's modification time ─────────────────────
date -r /etc/hosts '+%s' # mtime as epoch (GNU)
stat -c '%Y' /etc/hosts # same, via stat (more portable)
# ── Convert epoch back to a human date ────────────────────────
epoch=1749479400
date -d "@${epoch}" → Tue Jun 9 14:30:00 UTC 2026
date -d "@${epoch}" '+%Y-%m-%d %H:%M:%S' → 2026-06-09 14:30:00
# The @ prefix means "interpret as epoch seconds"
# ── Portable: extract individual fields from epoch ────────────
# Works on both GNU and BSD date
epoch_to_parts() {
local epoch=$1
IFS=' ' read -r year month day hour min sec \
<<< "$(date -d "@$epoch" '+%Y %m %d %H %M %S')"
printf 'year=%s month=%s day=%s hour=%s min=%s sec=%s\n' \
"$year" "$month" "$day" "$hour" "$min" "$sec"
}
3 — Date Arithmetic
Once you have epoch seconds, arithmetic is trivial — it's just integer addition and subtraction. The key constants: 60 seconds/minute, 3600 seconds/hour, 86400 seconds/day. For month and year arithmetic, delegate back to date -d since months have variable lengths.
# ── Add/subtract fixed intervals (pure arithmetic) ────────────
now=$(date '+%s')
one_hour_ago=$(( now - 3600 ))
one_day_ago=$(( now - 86400 ))
one_week_ago=$(( now - 86400 * 7 ))
in_30_days=$(( now + 86400 * 30 ))
# Convert result back to readable date
date -d "@$one_day_ago" '+%Y-%m-%d %H:%M:%S'
# ── Month/year arithmetic — delegate to date -d ───────────────
# Month lengths vary; don't do month arithmetic with 30*86400
next_month=$(date -d 'now + 1 month' '+%Y-%m-%d')
prev_month=$(date -d 'now - 1 month' '+%Y-%m-%d')
next_year=$(date -d 'now + 1 year' '+%Y-%m-%d')
# ── First and last day of current month ───────────────────────
first_day=$(date '+%Y-%m-01')
last_day=$(date -d "$(date '+%Y-%m-01') + 1 month - 1 day" '+%Y-%m-%d')
# Alternative: next month's first day minus 1 second
eom_epoch=$(date -d "$(date '+%Y-%m-01') + 1 month - 1 second" '+%s')
# ── Start/end of current day ──────────────────────────────────
today_start=$(date '+%Y-%m-%d 00:00:00')
today_start_epoch=$(date -d "$today_start" '+%s')
today_end_epoch=$(( today_start_epoch + 86400 - 1 ))
Calculating differences between two dates
# ── Difference between two epoch values ───────────────────────
start=$(date -d '2026-01-01' '+%s')
end=$(date -d '2026-06-09' '+%s')
diff_seconds=$(( end - start ))
diff_days=$(( diff_seconds / 86400 ))
diff_hours=$(( diff_seconds / 3600 ))
echo "$diff_days days" → 159 days
# ── Human-readable duration from seconds ──────────────────────
format_duration() {
local total=$1
local days=$(( total / 86400 ))
local hours=$(( (total % 86400) / 3600 ))
local mins=$(( (total % 3600) / 60 ))
local secs=$(( total % 60 ))
local result=""
(( days > 0 )) && result+="${days}d "
(( hours > 0 )) && result+="${hours}h "
(( mins > 0 )) && result+="${mins}m "
result+="${secs}s"
printf '%s' "$result"
}
format_duration 90061 → 1d 1h 1m 1s
format_duration 3723 → 1h 2m 3s
format_duration 45 → 45s
# ── Script execution timer ────────────────────────────────────
T_START=$SECONDS # $SECONDS counts seconds since shell started
# ... do work ...
elapsed=$(( SECONDS - T_START ))
echo "Completed in $(format_duration $elapsed)"
# Higher resolution: use date +%s%N for nanoseconds
t0=$(date '+%s%N')
# ... do work ...
t1=$(date '+%s%N')
ms=$(( (t1 - t0) / 1000000 ))
echo "Elapsed: ${ms}ms"
4 — Comparing Timestamps
Since epoch seconds are integers, all comparisons use standard integer operators: -lt, -gt, -eq, or (( )) arithmetic. This is far more reliable than comparing date strings lexicographically (which only works safely with ISO format YYYY-MM-DD).
# ── Is a deadline past? ───────────────────────────────────────
deadline=$(date -d '2026-12-31 23:59:59' '+%s')
now=$(date '+%s')
if (( now > deadline )); then
echo "Deadline has passed"
else
remaining=$(( deadline - now ))
echo "$(format_duration $remaining) until deadline"
fi
# ── Is a file older than N days? ──────────────────────────────
file_older_than() {
local file="$1"
local days="$2"
[[ -e "$file" ]] || return 0 # missing file: treat as infinitely old
local mtime
mtime=$(stat -c '%Y' "$file")
local cutoff=$(( $(date '+%s') - days * 86400 ))
(( mtime < cutoff ))
}
if file_older_than /var/cache/myapp/data.cache 7; then
echo "Cache is stale — refreshing"
refresh_cache
fi
# ── Find all files modified in the last N minutes ─────────────
cutoff=$(( $(date '+%s') - 30 * 60 )) # 30 minutes ago
while IFS= read -r -d '' f; do
mtime=$(stat -c '%Y' "$f")
(( mtime >= cutoff )) && echo "Recent: $f"
done < <(find /var/log -name '*.log' -print0)
# Better: let find do it
find /var/log -name '*.log' -newer <(date -d '30 minutes ago')
# ── Compare two ISO date strings directly (lexicographic — safe for YYYY-MM-DD)
d1="2026-01-15"
d2="2026-06-09"
[[ "$d1" < "$d2" ]] && echo "$d1 is earlier"
# This ONLY works reliably with YYYY-MM-DD or YYYYMMDD — never with locale dates
5 — Timezone Handling
The TZ environment variable controls which timezone date uses. You can set it inline for a single command, export it for the current script, or use it to convert between zones. Epoch seconds are always UTC — the timezone only affects how they're displayed or parsed.
# ── Display current time in multiple timezones ────────────────
TZ='UTC' date '+%H:%M %Z' → 13:30 UTC
TZ='America/New_York' date '+%H:%M %Z' → 09:30 EDT
TZ='Europe/London' date '+%H:%M %Z' → 14:30 BST
TZ='Asia/Tokyo' date '+%H:%M %Z' → 22:30 JST
# ── Convert a timestamp from one zone to another ──────────────
# Step 1: parse the local time as epoch (forces UTC interpretation)
# Step 2: display with the target TZ
ny_time="2026-06-09 09:30:00"
epoch=$(TZ='America/New_York' date -d "$ny_time" '+%s')
tokyo_time=$(TZ='Asia/Tokyo' date -d "@$epoch" '+%Y-%m-%d %H:%M %Z')
echo "$ny_time ET = $tokyo_time"
# ── Ensure a script always uses UTC ───────────────────────────
export TZ='UTC'
# All subsequent date calls use UTC regardless of system timezone
# Critical for cron jobs that run on servers across multiple timezones
# ── List available timezone names ─────────────────────────────
timedatectl list-timezones # systemd systems
ls /usr/share/zoneinfo/ # all systems
# ── Check current system timezone ─────────────────────────────
date '+%Z %z' → UTC +0000
timedatectl | grep 'Time zone' → Time zone: Europe/London (BST, +0100)
# ── DST-safe "is it past 9am in New York?" ────────────────────
# DON'T use fixed offsets like UTC-5 — DST changes them
# DO use named timezones — the OS handles DST automatically
ny_hour=$(TZ='America/New_York' date '+%-H') # %-H strips leading zero
(( ny_hour >= 9 && ny_hour < 17 )) && echo "NY business hours"
America/New_York, never abbreviations like EST — abbreviations are ambiguous (three different countries use CST with different UTC offsets).6 — Scheduling Logic: Day-of-Week and Time-Window Decisions
Scripts running under cron or as daemons often need to make decisions based on the current time: "only run on weekdays", "don't run during backup window", "send the digest every Monday". This is cleaner in the script itself than trying to encode all the logic in crontab syntax.
# ── Is it a weekday? ──────────────────────────────────────────
dow=$(date '+%u') # 1=Mon … 7=Sun (ISO weekday, most useful)
if (( dow >= 1 && dow <= 5 )); then
echo "Weekday — running job"
else
echo "Weekend — skipping"
exit 0
fi
# ── Is it Monday? (for weekly tasks) ─────────────────────────
if [[ "$(date '+%u')" == "1" ]]; then
send_weekly_digest
fi
# ── Is it the first day of the month? ─────────────────────────
if [[ "$(date '+%d')" == "01" ]]; then
generate_monthly_report
fi
# ── Is the current time inside a maintenance window? ──────────
# Maintenance: 02:00–04:00 UTC every night
in_maintenance_window() {
local h m
IFS=':' read -r h m <<< "$(TZ=UTC date '+%H:%M')"
local mins=$(( 10#$h * 60 + 10#$m )) # force base-10 (08, 09 are octal-safe)
(( mins >= 120 && mins < 240 )) # 02:00 = 120 mins, 04:00 = 240 mins
}
if in_maintenance_window; then
echo "In maintenance window — aborting"
exit 0
fi
# ── Wait until a specific time ────────────────────────────────
sleep_until() {
local target_epoch
target_epoch=$(date -d "$1" '+%s')
local now_epoch
now_epoch=$(date '+%s')
local wait_secs=$(( target_epoch - now_epoch ))
if (( wait_secs > 0 )); then
echo "Sleeping ${wait_secs}s until $1"
sleep "$wait_secs"
fi
}
sleep_until "03:00 tomorrow"
sleep_until "next Monday 08:00"
Handling the midnight rollover problem
# A maintenance window 22:00–02:00 crosses midnight
# Naive hour comparison breaks: is 23:00 in range [22,2]?
in_overnight_window() {
local start_h="$1" # e.g. 22
local end_h="$2" # e.g. 2
local cur_h
cur_h=$(date '+%-H') # current hour, no leading zero
if (( start_h > end_h )); then
# Window crosses midnight: in range if >= start OR < end
(( cur_h >= start_h || cur_h < end_h ))
else
# Normal window (same day)
(( cur_h >= start_h && cur_h < end_h ))
fi
}
in_overnight_window 22 2 # 22:00–02:00 maintenance window
if in_overnight_window 22 2; then
echo "Currently in maintenance window"
fi
# ── Is it the last day of the month? ──────────────────────────
# Trickier: "tomorrow is the first of next month"
is_last_day_of_month() {
local tomorrow_day
tomorrow_day=$(date -d 'tomorrow' '+%d')
[[ "$tomorrow_day" == "01" ]]
}
is_last_day_of_month && run_end_of_month_jobs
7 — Practical Patterns: Log Rotation and File Aging
#!/usr/bin/env bash
# ── Pattern: one log file per day ─────────────────────────────
LOG_DIR=/var/log/myapp
LOG_FILE="${LOG_DIR}/app_$(date '+%Y-%m-%d').log"
log() {
printf -v _ts '%(%Y-%m-%d %H:%M:%S)T' -1 # no fork
printf '[%s] %s\n' "$_ts" "$*" >> "$LOG_FILE"
}
# ── Delete logs older than N days ─────────────────────────────
rotate_logs() {
local dir="$1" keep_days="$2"
local cutoff=$(( $(date '+%s') - keep_days * 86400 ))
local file mtime deleted=0
while IFS= read -r -d '' file; do
mtime=$(stat -c '%Y' "$file")
if (( mtime < cutoff )); then
rm -f "$file"
(( deleted++ ))
fi
done < <(find "$dir" -maxdepth 1 -type f -name '*.log' -print0)
echo "Rotated: deleted $deleted logs older than $keep_days days"
}
rotate_logs "$LOG_DIR" 30
# ── Generate a date-stamped backup filename ────────────────────
backup_filename() {
local base="${1%.*}" # strip extension
local ext="${1##*.}" # get extension
local ts
printf -v ts '%(%Y%m%d_%H%M%S)T' -1
printf '%s_%s.%s' "$base" "$ts" "$ext"
}
backup_filename "config.yaml" → config_20260609_143005.yaml
backup_filename "database.sql" → database_20260609_143005.sql
8 — sleep, Fractional Seconds, and Polling Loops
# ── sleep accepts fractional seconds (GNU sleep) ──────────────
sleep 0.1 # 100ms
sleep 0.5 # 500ms
sleep 2.5 # 2.5 seconds
sleep "${POLL_INTERVAL:-5}" # configurable interval
# ── Exponential backoff ───────────────────────────────────────
retry_with_backoff() {
local max_attempts="${1}"; shift
local delay=1
local attempt
for (( attempt=1; attempt <= max_attempts; attempt++ )); do
if "$@"; then
return 0
fi
if (( attempt < max_attempts )); then
printf 'Attempt %d/%d failed — retrying in %ds\n' \
"$attempt" "$max_attempts" "$delay" >&2
sleep "$delay"
delay=$(( delay * 2 > 60 ? 60 : delay * 2 )) # cap at 60s
fi
done
return 1
}
retry_with_backoff 5 curl -sf https://api.example.com/health
# ── Poll until condition is met, with timeout ─────────────────
wait_for() {
local timeout="$1"; shift # timeout in seconds
local deadline=$(( $(date '+%s') + timeout ))
while (( $(date '+%s') < deadline )); do
"$@" && return 0
sleep 2
done
printf 'Timed out after %ds waiting for: %s\n' "$timeout" "$*" >&2
return 1
}
# Wait up to 60s for a service to become healthy
wait_for 60 curl -sf http://localhost:8080/health
echo "Service is up"
# ── The SECONDS built-in — no fork, precise to 1 second ───────
T0=$SECONDS
# ... work ...
echo "Elapsed: $(( SECONDS - T0 ))s"
# $SECONDS can be assigned to reset the counter:
SECONDS=0
# ... work ...
echo "Elapsed: ${SECONDS}s"
9 — Portability: GNU vs BSD date
# BSD date does NOT support -d "string" for parsing
# Instead use -j (don't set date) and -f FORMAT
# Parse a date string to epoch on macOS:
date -j -f '%Y-%m-%d' '2026-06-09' '+%s' → epoch int
# Parse with time on macOS:
date -j -f '%Y-%m-%d %H:%M:%S' '2026-06-09 14:30:00' '+%s'
# Date arithmetic on macOS with -v:
# -v +7d = add 7 days, -v -1m = subtract 1 month
date -v +7d '+%Y-%m-%d' # 7 days from now
date -v -1m '+%Y-%m-%d' # 1 month ago
date -v +1y -v +3d '+%Y-%m-%d' # 1 year + 3 days from now
# Convert epoch to date on macOS:
date -r 1749479400 '+%Y-%m-%d %H:%M:%S' # -r instead of @epoch
# Detect which date we have
if date --version >/dev/null 2>&1; then
DATE_TYPE="gnu"
else
DATE_TYPE="bsd"
fi
# Portable: parse a YYYY-MM-DD string to epoch
date_to_epoch() {
if [[ "$DATE_TYPE" == "gnu" ]]; then
date -d "$1" '+%s'
else
date -j -f '%Y-%m-%d' "$1" '+%s'
fi
}
# Portable: display epoch as formatted date
epoch_to_date() {
if [[ "$DATE_TYPE" == "gnu" ]]; then
date -d "@$1" "${2:+%Y-%m-%d}"
else
date -r "$1" "${2:++%Y-%m-%d}"
fi
}
# Fully portable: date arithmetic using only epoch + integer math
# Avoids calling date at all for simple add/subtract
now_epoch=$(date '+%s') # works on both
week_ago=$(( now_epoch - 7*86400 )) # pure arithmetic — fully portable
week_ago_date=$(epoch_to_date "$week_ago")
10 — Quick Reference
Key constants for arithmetic
| Period | Seconds | Expression |
|---|---|---|
| 1 minute | 60 | 60 |
| 1 hour | 3,600 | 60 * 60 |
| 1 day | 86,400 | 60 * 60 * 24 |
| 1 week | 604,800 | 86400 * 7 |
| 30 days | 2,592,000 | 86400 * 30 (approx — use date -d for months) |
| 365 days | 31,536,000 | 86400 * 365 (approx — use date -d for years) |
Essential one-liners
| Task | Command |
|---|---|
| Current epoch (no fork) | printf -v now '%(%s)T' -1 |
| Current timestamp for filenames | printf -v ts '%(%Y%m%d_%H%M%S)T' -1 |
| Parse date string to epoch | date -d 'YYYY-MM-DD HH:MM:SS' '+%s' |
| Convert epoch to date | date -d "@$epoch" '+%Y-%m-%d %H:%M:%S' |
| N days ago (epoch) | $(( $(date '+%s') - N * 86400 )) |
| Day of week (1=Mon, 7=Sun) | date '+%u' |
| Script elapsed time | $(( SECONDS - T0 )) |
| File modification time (epoch) | stat -c '%Y' FILE |
| Force base-10 for hours/mins | $(( 10#$h )) — prevents octal interpretation of 08, 09 |
| Sleep until a future time | sleep $(( $(date -d "TARGET" '+%s') - $(date '+%s') )) |
✏️ Exercises
These exercises build real utility functions and scripts you'll reach for regularly. Test each with edge cases: end of month, midnight rollovers, dates in the past and future.
age_report() that accepts a list of file paths and produces a formatted table showing each file's name, size, modification date (YYYY-MM-DD HH:MM:SS), age in a human-readable form (e.g. "3d 4h 12m"), and a status of FRESH (less than 1 day old), AGING (1–7 days), or STALE (more than 7 days). Sort output by age, oldest first.stat -c '%Y %s %n' to get mtime epoch, size, and name in one call. Convert age in seconds with your format_duration() function. Sort by epoch ascending using sort -n on the first field. Colour the STATUS column: green for FRESH, yellow for AGING, red for STALE.#!/usr/bin/env bash
set -euo pipefail
format_duration() {
local total=$1
local d=$(( total / 86400 ))
local h=$(( (total % 86400) / 3600 ))
local m=$(( (total % 3600) / 60 ))
local s=$(( total % 60 ))
local out=""
(( d > 0 )) && out+="${d}d "
(( h > 0 || d > 0 )) && out+="${h}h "
(( m > 0 || h > 0 || d > 0 )) && out+="${m}m "
out+="${s}s"
printf '%s' "$out"
}
age_report() {
local now
printf -v now '%(%s)T' -1
# Collect: mtime|size|path — then sort by mtime ascending
local data=()
local f
for f in "$@"; do
[[ -e "$f" ]] || { echo "skip: $f not found" >&2; continue; }
data+=( "$(stat -c '%Y|%s|%n' "$f")" )
done
# Header
printf '\033[1m%-35s %-10s %-20s %-14s %s\033[0m\n' \
"FILE" "SIZE" "MODIFIED" "AGE" "STATUS"
printf '%.0s─' {1..95}; echo
# Sort by mtime (field 1) ascending, oldest first
while IFS='|' read -r mtime size path; do
local age=$(( now - mtime ))
local age_str; age_str=$(format_duration "$age")
local mod_date; mod_date=$(date -d "@$mtime" '+%Y-%m-%d %H:%M:%S')
local name="${path##*/}"
local status colour
if (( age < 86400 )); then status="FRESH"; colour="\033[32m"
elif (( age < 86400 * 7 )); then status="AGING"; colour="\033[33m"
else status="STALE"; colour="\033[31m"
fi
printf '%-35s %-10s %-20s %-14s %b%s\033[0m\n' \
"$name" "$size" "$mod_date" "$age_str" "$colour" "$status"
done < <(printf '%s\n' "${data[@]}" | sort -t'|' -k1,1n)
}
# Test with some files
age_report /etc/hostname /etc/hosts /etc/passwd /etc/fstab
business_days.sh that calculates the number of business days (Monday–Friday) between two dates passed as arguments in YYYY-MM-DD format, and also generates a list of all the business day dates between them. It should correctly skip weekends. Bonus: accept an optional third argument, a file of holidays in YYYY-MM-DD format (one per line), and exclude those too.date -d "@$epoch" '+%u' to get the weekday (6=Sat, 7=Sun). Load the holidays file into an associative array for O(1) lookup: holidays["2026-01-01"]=1.#!/usr/bin/env bash
# business_days.sh START_DATE END_DATE [HOLIDAYS_FILE]
set -euo pipefail
START="${1:?Usage: business_days.sh YYYY-MM-DD YYYY-MM-DD [holidays.txt]}"
END="${2:?Usage: business_days.sh YYYY-MM-DD YYYY-MM-DD [holidays.txt]}"
HOLIDAY_FILE="${3:-}"
# Load holidays into associative array
declare -A holidays=()
if [[ -n "$HOLIDAY_FILE" && -f "$HOLIDAY_FILE" ]]; then
while IFS= read -r hday; do
[[ -z "$hday" || "$hday" == '#'* ]] && continue
holidays["$hday"]=1
done < "$HOLIDAY_FILE"
echo "Loaded ${#holidays[@]} holiday(s)"
fi
# Parse start/end to epoch (normalised to midnight)
start_epoch=$(date -d "$START 00:00:00" '+%s')
end_epoch=$(date -d "$END 00:00:00" '+%s')
(( end_epoch >= start_epoch )) || {
echo "Error: END must be >= START" >&2; exit 1
}
count=0
business_days=()
curr=$start_epoch
while (( curr <= end_epoch )); do
dow=$(date -d "@$curr" '+%u') # 1=Mon … 7=Sun
ymd=$(date -d "@$curr" '+%Y-%m-%d')
if (( dow <= 5 )) && [[ -z "${holidays[$ymd]+x}" ]]; then
business_days+=( "$ymd" )
(( count++ ))
fi
(( curr += 86400 ))
done
# Output
printf '\nBusiness days from %s to %s: \033[1m%d\033[0m\n\n' \
"$START" "$END" "$count"
printf '%s\n' "${business_days[@]}"
run_window.sh that wraps any command and enforces a time window: it should only execute the command if the current time in a specified timezone falls within a configured start and end time. If outside the window it should print a clear message and exit 0 (so cron doesn't flag it as an error). Make the window configurable via environment variables: WINDOW_TZ, WINDOW_START (HH:MM), WINDOW_END (HH:MM), and optionally WINDOW_DAYS (comma-separated: Mon,Tue,Wed,Thu,Fri).TZ="$WINDOW_TZ" date '+%H %M %u' to get the current hour, minute, and weekday in the target timezone. Convert HH:MM to minutes-since-midnight for comparison, handling the midnight wrap. Parse WINDOW_DAYS with IFS=, and check the current weekday abbreviation.#!/usr/bin/env bash
# run_window.sh — only execute CMD if current time is in the configured window
# Env vars: WINDOW_TZ, WINDOW_START (HH:MM), WINDOW_END (HH:MM), WINDOW_DAYS
set -euo pipefail
WINDOW_TZ="${WINDOW_TZ:-UTC}"
WINDOW_START="${WINDOW_START:-09:00}"
WINDOW_END="${WINDOW_END:-17:00}"
WINDOW_DAYS="${WINDOW_DAYS:-}" # empty = any day
(( $# > 0 )) || { echo "Usage: run_window.sh COMMAND [ARGS]" >&2; exit 1; }
# Get current time components in the target timezone
read -r cur_h cur_m cur_dow_n cur_dow_s \
<<< "$(TZ="$WINDOW_TZ" date '+%-H %-M %u %a')"
# Minutes-since-midnight helper
hhmm_to_mins() {
local h=$(( 10#${1%%:*} ))
local m=$(( 10#${1##*:} ))
printf '%d' $(( h * 60 + m ))
}
cur_mins=$(( 10#$cur_h * 60 + 10#$cur_m ))
start_mins=$(hhmm_to_mins "$WINDOW_START")
end_mins=$(hhmm_to_mins "$WINDOW_END")
# Check time window (handles overnight wrap)
in_window=0
if (( start_mins <= end_mins )); then
(( cur_mins >= start_mins && cur_mins < end_mins )) && in_window=1
else
(( cur_mins >= start_mins || cur_mins < end_mins )) && in_window=1
fi
# Check day-of-week constraint
day_ok=1
if [[ -n "$WINDOW_DAYS" ]]; then
day_ok=0
IFS=',' read -r -a allowed_days <<< "$WINDOW_DAYS"
for d in "${allowed_days[@]}"; do
[[ "${d^}" == "${cur_dow_s^}" ]] && { day_ok=1; break; }
done
fi
# Print summary
printf '[window] TZ=%s now=%s %02d:%02d window=%s-%s days=%s\n' \
"$WINDOW_TZ" "$cur_dow_s" "$cur_h" "$cur_m" \
"$WINDOW_START" "$WINDOW_END" "${WINDOW_DAYS:-any}" >&2
if (( in_window && day_ok )); then
printf '[window] \033[32mIn window\033[0m — running: %s\n' "$*" >&2
exec "$@"
else
printf '[window] \033[33mOutside window\033[0m — skipping\n' >&2
exit 0
fi
# Example usage (in crontab, runs every 15 min but only executes in window):
# */15 * * * * WINDOW_TZ=America/New_York WINDOW_START=09:00 WINDOW_END=17:00 \
# WINDOW_DAYS=Mon,Tue,Wed,Thu,Fri \
# /usr/local/bin/run_window.sh /opt/myapp/process_orders.sh
timed_backup.sh that backs up a directory by creating a timestamped .tar.gz archive, then deletes archives older than a configurable retention period (KEEP_DAYS, default 14). It should log each action with a timestamp, report the size of the new archive and how much space was freed by deletions, and produce a one-line summary at the end. Use printf -v for all timestamp generation inside the script to avoid unnecessary forks.printf -v ts '%(%Y%m%d_%H%M%S)T' -1 for the archive filename. After creating the archive, use stat -c '%s' for its size. For deletion, loop over existing archives and compare stat -c '%Y' mtime against the cutoff epoch. Track freed bytes in a counter.#!/usr/bin/env bash
# timed_backup.sh SOURCE_DIR BACKUP_DIR
set -euo pipefail
SOURCE="${1:?Usage: timed_backup.sh SOURCE_DIR BACKUP_DIR}"
DEST="${2:?Usage: timed_backup.sh SOURCE_DIR BACKUP_DIR}"
KEEP_DAYS=${KEEP_DAYS:-14}
[[ -d "$SOURCE" ]] || { echo "Error: $SOURCE not a directory" >&2; exit 1; }
mkdir -p "$DEST"
# Logging (no fork — printf -v for timestamp)
log() {
local _ts
printf -v _ts '%(%Y-%m-%d %H:%M:%S)T' -1
printf '[%s] %s\n' "$_ts" "$*"
}
# Human-readable bytes
human_bytes() {
local b=$1
if (( b >= 1073741824 )); then printf '%.1f GB' "$(bc <<< "scale=1; $b/1073741824")"
elif (( b >= 1048576 )); then printf '%.1f MB' "$(bc <<< "scale=1; $b/1048576")"
elif (( b >= 1024 )); then printf '%.1f KB' "$(bc <<< "scale=1; $b/1024")"
else printf '%d B' "$b"
fi
}
# ── Step 1: Create archive ─────────────────────────────────────
local ts
printf -v ts '%(%Y%m%d_%H%M%S)T' -1
source_name="${SOURCE##*/}"
archive="${DEST}/${source_name}_${ts}.tar.gz"
log "Starting backup: $SOURCE -> $archive"
tar -czf "$archive" -C "$(dirname "$SOURCE")" "$source_name"
archive_size=$(stat -c '%s' "$archive")
log "Archive created: $(human_bytes $archive_size)"
# ── Step 2: Delete old archives ───────────────────────────────
cutoff=$(( $(date '+%s') - KEEP_DAYS * 86400 ))
freed=0
deleted=0
while IFS= read -r -d '' old; do
[[ "$old" == "$archive" ]] && continue # don't delete what we just made
mtime=$(stat -c '%Y' "$old")
if (( mtime < cutoff )); then
fsize=$(stat -c '%s' "$old")
rm -f "$old"
(( freed += fsize ))
(( deleted++ ))
log "Deleted old archive: ${old##*/} ($(human_bytes $fsize))"
fi
done < <(find "$DEST" -maxdepth 1 -name "${source_name}_*.tar.gz" -print0)
# ── Summary ────────────────────────────────────────────────────
log "Done. Created: $(human_bytes $archive_size) Freed: $(human_bytes $freed) Deleted: $deleted archive(s)"