Skip to content

Instantly share code, notes, and snippets.

@coderbyheart
Last active September 25, 2026 12:37
Show Gist options
  • Select an option

  • Save coderbyheart/b81406df20171baba548b154630f93ad to your computer and use it in GitHub Desktop.

Select an option

Save coderbyheart/b81406df20171baba548b154630f93ad to your computer and use it in GitHub Desktop.

Show the time to your next Outlook meeting in your zsh prompt

Adds a countdown like this to your zsh prompt:

(next meeting: 15:30, in 1h25m)

It only shows up when a meeting starts within the next 4 hours. It's green normally, yellow under 15 minutes and red under 5 minutes.

How it works

Outlook ──(published ICS link)──▶ next-meeting (cron, every 5 min) ──▶ ~/.cache/next-meeting ──▶ zsh prompt
  • Your prompt never makes a network request, so it stays instant. A cron job downloads the calendar in the background and writes the upcoming meetings to a cache file.
  • The prompt works out the countdown from the cache every time it's drawn, so the minutes stay accurate between refreshes.
  • If you're offline, the prompt keeps using the last downloaded data.

Requirements

  • zsh
  • uv. The script declares its Python dependencies (icalendar, recurring-ical-events) inline, and uv installs them automatically. You don't need to pip install anything.
  • cron, or any other scheduler
  • A Microsoft 365 / Outlook calendar that you're allowed to publish. Some companies turn publishing off.

Setup

1. Publish your calendar

In Outlook on the web, go to Settings → Calendar → Shared calendars → Publish a calendar. Pick your calendar and a permission level, click Publish, then copy the ICS link.

The permission level decides what the feed contains:

Permission Event titles in the feed
Can view when I'm busy Busy, Tentative, Free, Away
Can view titles and locations Real meeting titles
Can view all details Real meeting titles and details

"Can view when I'm busy" is enough for the countdown.

Warning

Anyone who has the ICS link can read your calendar at the permission level you picked. Treat the link like a password: don't commit it, and don't paste it into your crontab or dotfiles. If it leaks, go back to the same settings page and reset the link.

2. Store the link

mkdir -p ~/.config/next-meeting
(umask 077; echo 'https://outlook.office365.com/owa/calendar/…/calendar.ics' > ~/.config/next-meeting/ics-url)

3. Install the fetcher

Save the script below as ~/.local/bin/next-meeting and make it executable with chmod +x ~/.local/bin/next-meeting.

#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["icalendar", "recurring-ical-events"]
# ///
"""Fetch the published Outlook calendar and cache today's upcoming meetings.

Writes one "<epoch>\t<summary>" line per upcoming timed event (next 24h) to
~/.cache/next-meeting, which the zsh prompt reads.
"""
import datetime as dt
import os
import urllib.request
from pathlib import Path

import icalendar
import recurring_ical_events

url = (Path.home() / ".config/next-meeting/ics-url").read_text().strip()
cache = Path.home() / ".cache/next-meeting"

cal = icalendar.Calendar.from_ical(urllib.request.urlopen(url, timeout=20).read())
now = dt.datetime.now(dt.timezone.utc)

lines = []
for e in recurring_ical_events.of(cal).between(now, now + dt.timedelta(days=1)):
    start = e["DTSTART"].dt
    if not isinstance(start, dt.datetime):  # all-day event
        continue
    if str(e.get("STATUS", "")).upper() == "CANCELLED":
        continue
    summary = " ".join(str(e.get("SUMMARY", "")).split())  # no tabs/newlines
    if summary.lower().startswith(("canceled:", "cancelled:")):
        continue
    # Not meetings (availability-only feeds use these as titles)
    if str(e.get("TRANSP", "")).upper() == "TRANSPARENT" or summary in (
        "Free",
        "Away",
        "Working elsewhere",
    ):
        continue
    if start.tzinfo is None:
        start = start.astimezone()  # floating time: treat as local
    if start > now:
        lines.append((int(start.timestamp()), summary))

cache.parent.mkdir(parents=True, exist_ok=True)
tmp = cache.with_suffix(".tmp")
tmp.write_text("".join(f"{ts}\t{s}\n" for ts, s in sorted(lines)))
os.replace(tmp, cache)

Run it once and check the result:

~/.local/bin/next-meeting && cat ~/.cache/next-meeting

You should see lines like 1790346600 Busy (a Unix timestamp, a tab, then the event title). The file is empty if you have no more meetings in the next 24 hours.

The script skips:

  • all-day events
  • cancelled meetings
  • entries marked Free, Away or Working elsewhere

4. Refresh it every 5 minutes

Run crontab -e and add:

*/5 * * * * PATH=/usr/local/bin:/usr/bin:/bin $HOME/.local/bin/next-meeting >/dev/null 2>&1

cron starts jobs with a minimal PATH, so make sure it includes the directory where uv is installed. Run which uv to find it. For a default install it's ~/.local/bin. If so, add $HOME/.local/bin to the PATH in the cron line.

5. Add it to your prompt

Add this to ~/.zshrc:

zmodload zsh/datetime   # provides $EPOCHSECONDS and strftime
setopt prompt_subst     # lets the prompt run $(...) each time it's drawn

# Next meeting countdown (only shown if a meeting starts within 4h)
function meeting_prompt_section {
  local f=~/.cache/next-meeting start summary
  [[ -r $f ]] || return
  while IFS=$'\t' read -r start summary; do
    (( start > EPOCHSECONDS )) || continue
    local -i mins=$(( (start - EPOCHSECONDS) / 60 ))
    (( mins < 240 )) || return
    local colour=green
    (( mins < 15 )) && colour=yellow
    (( mins < 5 )) && colour=red
    local in=${mins}m
    (( mins >= 60 )) && in="$(( mins / 60 ))h$(( mins % 60 ))m"
    local at; strftime -s at %H:%M $start
    echo "(%F{$colour}next meeting: ${at}, in ${in}%f)"
    return
  done < $f
}

RPROMPT='$(meeting_prompt_section)'"$RPROMPT"

Open a new terminal, or run source ~/.zshrc.

If your theme or framework sets RPROMPT after this point, for example oh-my-zsh, add the snippet after the line that loads the theme. You can also put $(meeting_prompt_section) anywhere in PROMPT instead.

Customising

  • Show the meeting title. If your feed has real titles (see step 1), change the echo line to:
    echo "(%F{$colour}${at} ${${summary[1,25]}//\%/%%}, in ${in}%f)"
    This cuts the title at 25 characters. It also escapes %, so a title that contains % can't break the prompt.
  • Change the 4-hour window. Edit mins < 240.
  • Refresh more or less often. Change */5 in the cron line. Outlook only updates the published feed every so often anyway, so refreshing more often than every few minutes won't get you newer data.

Troubleshooting

  • Nothing shows up. Run ~/.local/bin/next-meeting by hand and look for errors, then check ~/.cache/next-meeting. If the file is empty, you have no meetings in the next 24 hours, or they were all skipped.
  • It works when you run it by hand, but the cache never updates. cron probably can't find uv. See step 4.
  • Times are off by an hour or more. Check that your system timezone is set correctly. The script uses the timezones in the feed. Events without a timezone (rare) are treated as local time.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment