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.

DirectiveMeaningExample output
%Y4-digit year2026
%y2-digit year26
%mMonth (01–12)06
%dDay of month (01–31)09
%jDay of year (001–366)160
%HHour, 24h (00–23)14
%IHour, 12h (01–12)02
%MMinute (00–59)30
%SSecond (00–60)05
%NNanoseconds (GNU only)123456789
%sUnix epoch seconds since 1970-01-01 UTC1749470405
%AFull weekday nameMonday
%aAbbreviated weekdayMon
%BFull month nameJune
%bAbbreviated month nameJun
%uDay of week (1=Mon … 7=Sun)1
%wDay of week (0=Sun … 6=Sat)1
%WWeek number of year (Mon-based)23
%ZTimezone abbreviationUTC
%zUTC offset+0100
%:zUTC offset with colon (GNU only)+01:00
%FISO date shorthand: %Y-%m-%d2026-06-09
%TTime shorthand: %H:%M:%S14:30:05
%RHour:minute: %H:%M14:30
%DUS date: %m/%d/%y06/09/26
%%Literal percent sign%
🐧 Common date format recipes
# ── 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
The 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.
GNU date vs BSD date (macOS): The examples in this chapter use GNU 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.

🐧 Getting epoch seconds — current time and from a date string
# ── 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.

🐧 Adding and subtracting time periods
# ── 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

🐧 Duration and age calculations
# ── 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 timestamp past? Is a file too old? Is it business hours?
# ── 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.

🐧 Working with timezones
# ── 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"
Timezone names come from the IANA tz database (also called the Olson database). Always use full names like 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.

🐧 Day, week, and time-window decision patterns
# ── 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

🐧 Time windows that span midnight
# 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

🐧 Date-stamped log files and rotation
#!/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 patterns and high-resolution timing
# ── 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 (macOS) — different syntax for arithmetic and parsing
# 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
🐧 Writing portable date code for both GNU and BSD
# 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

PeriodSecondsExpression
1 minute6060
1 hour3,60060 * 60
1 day86,40060 * 60 * 24
1 week604,80086400 * 7
30 days2,592,00086400 * 30 (approx — use date -d for months)
365 days31,536,00086400 * 365 (approx — use date -d for years)

Essential one-liners

TaskCommand
Current epoch (no fork)printf -v now '%(%s)T' -1
Current timestamp for filenamesprintf -v ts '%(%Y%m%d_%H%M%S)T' -1
Parse date string to epochdate -d 'YYYY-MM-DD HH:MM:SS' '+%s'
Convert epoch to datedate -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 timesleep $(( $(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.

Exercise 1
Write a function called 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.
Hint: use 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.
Sample Solution
#!/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
Exercise 2
Write a script called 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.
Hint: iterate day by day from start epoch to end epoch, adding 86400 each time. For each day, check 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.
Sample Solution
#!/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[@]}"
Exercise 3
Write a script called 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).
Hint: use 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.
Sample Solution
#!/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
Exercise 4
Write a script called 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.
Hint: use 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.
Sample Solution
#!/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)"