From 753cce4c30bd02950633052e23d63caa92be8bbc Mon Sep 17 00:00:00 2001 From: bytebeast Date: Tue, 8 Sep 2026 11:30:04 -0700 Subject: [PATCH] feat(tzc): add support for multiple --to 's (AH-2026090882124) Refs: AH-2026090882124 --- README.md | 101 +++++++++++++++++++++++++++++++------ tzc | 148 ++++++++++++++++++++++++++++++++++++++++-------------- 2 files changed, 198 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 52d4ced..2865a2a 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ It works the other way too. Someone in a different timezone drops a timestamp in chat ("it broke at 14:35"), and you just paste it in and convert it into your own timezone without a second thought. +When an incident spans a team spread across three continents, you can pass +`--to` more than once and get every one of those zones in a single block - handy +for a timeline in the postmortem, or for working out whether paging Bengaluru at +that hour was reasonable. + And because your day probably revolves around a handful of the same zones, you can wrap `tzc` in a couple of **shell aliases** so a conversion is one short word away. @@ -35,8 +40,8 @@ word away. `tzc` reads basically any timestamp you are likely to copy (SQL, ISO-8601 / Kubernetes, syslog, Apache, `date` output, Unix epoch in seconds through nanoseconds, and even plain-English "31 days, 4 hours, 25 minutes ago"), lets -you pick the target zone with an `fzf` fuzzy search, and prints the result in a -clean Catppuccin-colored block. +you pick one or more target zones with an `fzf` fuzzy search, and prints the +results in a clean Catppuccin-colored block. --- @@ -50,12 +55,15 @@ clean Catppuccin-colored block. nanoseconds, detected automatically - **Human-readable relative input** - type `31 days, 4 hours, 25 minutes ago` or `in 3 hours` and it resolves against now +- **Many zones at once** - repeat `--to` (or give it a comma-separated list) to + convert one timestamp into every zone your team sits in, in one block - **fzf timezone picker** - fuzzy-search the full IANA list, annotated with each - zone's abbreviation and current UTC offset (search `IST` or `-8h`) + zone's abbreviation and current UTC offset (search `IST` or `-8h`), and press + `TAB` to select several zones at once - **Tells you how long ago** - every result includes a friendly relative time like `31 days, 4 hours ago` - **Shell-alias friendly** - pass `--to` to skip the picker and bake your common - conversions into one-word aliases + conversions - including whole-team, multi-zone ones - into one-word aliases - **Readable output** - Catppuccin Mocha colors that auto-disable when piped or when `NO_COLOR` is set - **Single file, standard library only** - the one optional extra is `fzf`, and @@ -67,8 +75,9 @@ clean Catppuccin-colored block. - **Python 3.9+** (uses the standard-library `zoneinfo` module) - **[fzf](https://github.com/junegunn/fzf)** for the interactive timezone - picker. Optional: if `fzf` is not installed, or you pass `--to`, `tzc` falls - back to a plain text prompt or that value. + picker, including `TAB` multi-select. Optional: if `fzf` is not installed, or + you pass `--to`, `tzc` falls back to a plain text prompt (which accepts a + comma-separated list of zones) or those values. - **IANA tz database** - present on macOS and most Linux systems. On a minimal install you may need it from PyPI: `pip install tzdata`. @@ -109,6 +118,12 @@ tzc "2026-07-24T14:35:18Z" # Skip the picker by naming the target zone tzc "2026-07-24 14:35:18" --to America/New_York +# Repeat --to for several zones at once - one line of output per zone +tzc "2026-07-24T14:35:18Z" --to America/New_York --to Europe/London --to Asia/Kolkata + +# Same thing, comma-separated (mix and match if you like) +tzc "2026-07-24T14:35:18Z" --to America/New_York,Europe/London,Asia/Kolkata + # A teammate pasted their local time in chat; convert it to yours tzc "Fri Jul 24 07:19:59 PDT 2026" --to Europe/London @@ -133,6 +148,39 @@ tzc "in 3 hours" --to Asia/Kolkata tzc "2026-07-24T14:35:18Z" --to UTC --no-color ``` +### One timestamp, every zone your team is in + +`--to` is repeatable, and each one also accepts a comma-separated list, so these +are all equivalent: + +```bash +tzc 1784903718 --to America/Los_Angeles --to Europe/London --to Asia/Tokyo +tzc 1784903718 --to America/Los_Angeles,Europe/London,Asia/Tokyo +tzc 1784903718 --to America/Los_Angeles,Europe/London --to Asia/Tokyo +``` + +You get one line per zone, in the order you listed them, with everything else in +the block unchanged: + +``` + Timezone Conversion + --------------------- +Input string : 2026-07-24T14:35:18Z + -> parsed with explicit timezone +Entered time : 2026-07-24 14:35:18 UTC (+0000) +Converted to : 2026-07-24 07:35:18 PDT (-0700) [America/Los_Angeles] + 2026-07-24 10:35:18 EDT (-0400) [America/New_York] + 2026-07-24 15:35:18 BST (+0100) [Europe/London] + 2026-07-24 20:05:18 IST (+0530) [Asia/Kolkata] + 2026-07-24 23:35:18 JST (+0900) [Asia/Tokyo] +UTC : 2026-07-24 14:35:18 UTC (+0000) +Epoch seconds: 1784903718 +Relative : 46 days, 3 hours, 50 minutes ago +``` + +Duplicate zones are dropped, so a comma list that overlaps with a flag (or with +an alias you wrapped it in) will not print the same zone twice. + ### The fzf picker Run `tzc` with no `--to` and you get a fuzzy-searchable list of every IANA zone, @@ -147,6 +195,9 @@ Europe/London GMT/BST +1h Search by zone name (`tokyo`), by abbreviation (`IST` surfaces India, Ireland, and Israel so you can pick the right one), or even by offset (`-8h`). +The picker is multi-select: press `TAB` to mark a zone (`Shift-TAB` to unmark), +mark as many as you want, then `Enter` to convert to all of them at once. + --- ## Shell aliases @@ -178,6 +229,23 @@ alias tzsyd='tzc --to Australia/Sydney' # SYD Sydney # usage: tzsfo "2026-07-24T14:35:18Z" ``` +Since `--to` is repeatable, an alias can cover a whole group of people at once - +your on-call rotation, the team you hand off to, or everyone who will read the +incident timeline: + +```bash +# the whole team, in one shot +alias tzteam='tzc --to America/Los_Angeles --to America/New_York --to Europe/London --to Asia/Kolkata' + +# follow-the-sun on-call handoff: who is awake right now? +alias tzoncall='tzc --to America/Los_Angeles,Europe/Berlin,Asia/Singapore' + +# just the two ends of your handoff window +alias tzhandoff='tzc --to America/New_York,Asia/Kolkata' + +# usage: tzteam "2026-07-24T14:35:18Z" +``` + Prefer functions so you can convert whatever is on your clipboard in one shot: ```bash @@ -186,9 +254,13 @@ tznow() { tzc "$(pbpaste)" --to America/New_York; } # Linux with xclip - convert the clipboard to your local zone tzhere() { tzc "$(xclip -o -selection clipboard)"; } + +# clipboard -> your zone plus everyone you are working the incident with +tzall() { tzc "$(pbpaste)" --to America/Los_Angeles,Europe/London,Asia/Kolkata; } ``` -Now copy a timestamp out of a log, run `tznow`, and you are done. +Now copy a timestamp out of a log, run `tznow` (or `tzall` when you need to +share it), and you are done. --- @@ -217,14 +289,15 @@ timezone unless you pass `--from`. ## Options -| Flag | Description | -| ------------- | -------------------------------------------------------------- | -| `--to ZONE` | Target timezone; skips the fzf picker (e.g. `--to Asia/Tokyo`) | -| `--from ZONE` | Timezone to assume for naive inputs (default: your local zone) | -| `--no-color` | Disable the Catppuccin colored output | +| Flag | Description | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | +| `--to ZONE` | Target timezone; skips the fzf picker (e.g. `--to Asia/Tokyo`). Repeatable, and accepts a comma-separated list, to convert to several zones at once | +| `--from ZONE` | Timezone to assume for naive inputs (default: your local zone) | +| `--no-color` | Disable the Catppuccin colored output | -Output includes the parsed input, the entered time, the converted time, the UTC -equivalent, the Unix epoch, and a friendly relative time. +Output includes the parsed input, the entered time, the converted time (one line +per `--to` zone), the UTC equivalent, the Unix epoch, and a friendly relative +time. --- diff --git a/tzc b/tzc index 612c459..0ea2268 100755 --- a/tzc +++ b/tzc @@ -5,8 +5,8 @@ tzconvert.py Interactively: 1. Ask you for a date/time string (auto-detects ~10 common formats). - 2. Let you pick a target timezone with fzf. - 3. Print the time you entered, the converted time, and a human-readable + 2. Let you pick one or more target timezones with fzf. + 3. Print the time you entered, the converted time(s), and a human-readable "2 days, 4 hours, 5 minutes ago" style relative time. Recognized input formats @@ -33,11 +33,24 @@ months, years (and common short forms like sec/min/hr/d/wk/mo/yr). "now" and Naive timestamps (no timezone in the string) are assumed to be in your LOCAL timezone by default; override with --from "Region/City" (e.g. --from UTC). +Converting to several timezones at once +--------------------------------------- +--to may be repeated, and each one also accepts a comma-separated list, so all +of these print three converted lines: + + tzc 1721831718 --to Asia/Tokyo --to Europe/London --to America/New_York + tzc 1721831718 --to Asia/Tokyo,Europe/London,America/New_York + tzc 1721831718 --to Asia/Tokyo,Europe/London --to America/New_York + +Duplicates are dropped and the order you gave is preserved. In the fzf picker, +press TAB to mark several zones (Shift-TAB to unmark) and Enter to convert to +all of them at once. + Dependencies ------------ * Python 3.9+ (uses the stdlib zoneinfo module). * fzf on your PATH for interactive timezone picking. If fzf is missing, or - you pass --to, the script falls back to a plain text prompt / that value. + you pass --to, the script falls back to a plain text prompt / those values. * On some minimal Linux installs you may need the IANA tz database: pip install tzdata (only if zoneinfo can't find zones) """ @@ -68,7 +81,7 @@ except ImportError: # Python < 3.9 import os __author__ = "github.com/bytebeast" -APP_VERSION = "1.1.1" +APP_VERSION = "1.2.0" # ============================================================ # Catppuccin (Mocha) color scheme @@ -413,21 +426,30 @@ def build_zone_lines(): return lines -def pick_timezone_with_fzf(): - """Pipe the annotated IANA timezone list into fzf; return the chosen zone.""" +def pick_timezones_with_fzf(): + """ + Pipe the annotated IANA timezone list into fzf and return the chosen zone + names as a list. fzf runs in --multi mode, so TAB marks several zones and + every marked line comes back. + + Returns None if fzf isn't installed (the caller falls back to a text + prompt), or an empty list if the picker was cancelled. + """ lines = "\n".join(build_zone_lines()) try: proc = subprocess.run( [ "fzf", - "--prompt=Convert to timezone (try an abbrev like IST)> ", + # Multi-select: TAB marks a zone, Shift-TAB unmarks it. + "--multi", + "--prompt=Convert to timezone(s) (TAB to pick several)> ", "--height=60%", "--reverse", "--border", # Search across the whole line so both zone name AND abbrev match. "--nth=1..", "--tiebreak=begin", - "--header=Type a zone name or an abbreviation (EST, PDT, IST, JST...)", + "--header=Type a zone name or an abbreviation (EST, PDT, IST, JST...). TAB selects multiple zones, Enter confirms.", ], input=lines, text=True, @@ -435,28 +457,62 @@ def pick_timezone_with_fzf(): ) except FileNotFoundError: return None # fzf not installed -> caller falls back to a text prompt - choice = proc.stdout.strip() - if not choice: - return None - # The zone name is the first whitespace-delimited token (IANA names have no - # spaces); the rest of the line is just the abbreviation annotation. - return choice.split()[0] + # One selection per line. The zone name is the first whitespace-delimited + # token (IANA names have no spaces); the rest is the abbrev/offset annotation. + return [line.split()[0] for line in proc.stdout.splitlines() if line.strip()] -def resolve_target_tz(cli_value): +def split_zone_names(values): """ - Decide the target timezone: - --to given -> use it directly (no fzf). - else -> fzf picker. - fzf missing -> plain text prompt. + Flatten repeated --to flags into a list of zone names, also splitting on + commas so `--to Asia/Tokyo,Europe/London` works like two separate flags. """ - if cli_value: - return ZoneInfo(cli_value) + names = [] + for value in values or []: + for part in value.split(","): + part = part.strip() + if part: + names.append(part) + return names + + +def load_zone(name): + """ZoneInfo(name) with a friendly error instead of a traceback.""" + try: + return ZoneInfo(name) + except Exception: + raise ValueError( + f"Unknown timezone: {name!r} (expected an IANA name like Asia/Tokyo)" + ) + - choice = pick_timezone_with_fzf() - if choice is None: - choice = input("Target timezone (e.g. America/New_York): ").strip() - return ZoneInfo(choice) +def resolve_target_tzs(cli_values): + """ + Decide the target timezone(s), returning a list of ZoneInfo: + --to given (repeatable, comma-separated) -> use those, in order, no fzf. + else -> fzf multi-select picker. + fzf missing -> plain text prompt. + Duplicates are dropped while preserving the order they were given in. + """ + names = split_zone_names(cli_values) + + if not names: + names = pick_timezones_with_fzf() + if names is None: # fzf not installed + answer = input( + "Target timezone(s), comma-separated (e.g. America/New_York, Asia/Tokyo): " + ) + names = split_zone_names([answer]) + if not names: + raise ValueError("No target timezone selected.") + + zones, seen = [], set() + for name in names: + zone = load_zone(name) + if zone.key not in seen: + seen.add(zone.key) + zones.append(zone) + return zones # ============================================================ @@ -506,7 +562,15 @@ def main(): description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) ap.add_argument("date", nargs="?", help="Date string (prompted if omitted).") - ap.add_argument("--to", help="Target timezone; skips fzf, e.g. Asia/Tokyo.") + ap.add_argument( + "--to", + action="append", + metavar="TZ", + help=( + "Target timezone; skips fzf, e.g. Asia/Tokyo. Repeat the flag " + "(or pass a comma-separated list) to convert to several zones at once." + ), + ) ap.add_argument( "--from", dest="from_tz", @@ -537,18 +601,24 @@ def main(): print(f"Error: {e}", file=sys.stderr) sys.exit(1) - # 3) Choose the target timezone (fzf). - target = resolve_target_tz(args.to) - converted = entered.astimezone(target) + # 3) Choose the target timezone(s) (fzf, unless --to was given). + try: + targets = resolve_target_tzs(args.to) + except ValueError as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + conversions = [(target, entered.astimezone(target)) for target in targets] # 4) Report (Catppuccin Mocha palette). fmt = "%Y-%m-%d %H:%M:%S %Z (%z)" def row(label, value, value_color, bold=False): - # Fixed-width mauve label, then the colored value. + # Fixed-width mauve label, then the colored value. An empty label gives + # a blank, colon-less continuation line for extra converted zones. + separator = paint(":", "surface") if label else " " print( f"{paint(f'{label:<13}', 'mauve')}" - f"{paint(':', 'surface')} " + f"{separator} " f"{paint(value, value_color, bold=bold)}" ) @@ -558,12 +628,16 @@ def main(): row("Input string", raw, "text", bold=True) print(f"{'':<13} {paint(' -> ' + note, 'subtext')}") row("Entered time", entered.strftime(fmt), "sky") - row( - "Converted to", - f"{converted.strftime(fmt)} [{target.key}]", - "green", - bold=True, - ) + # Pad the timestamps to a common width so the [zone] tags line up when + # several zones are converted at once (%Z varies: JST, GMT+3, +0530, ...). + width = max(len(converted.strftime(fmt)) for _, converted in conversions) + for index, (target, converted) in enumerate(conversions): + row( + "Converted to" if index == 0 else "", + f"{converted.strftime(fmt):<{width}} [{target.key}]", + "green", + bold=True, + ) row("UTC", entered.astimezone(UTC).strftime(fmt), "teal") row("Epoch seconds", str(int(entered.timestamp())), "peach") row("Relative", human_delta(entered), "yellow")