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.
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.
- zsh
- uv. The script declares its Python
dependencies (
icalendar,recurring-ical-events) inline, anduvinstalls them automatically. You don't need topip installanything. - cron, or any other scheduler
- A Microsoft 365 / Outlook calendar that you're allowed to publish. Some companies turn publishing off.
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.
mkdir -p ~/.config/next-meeting
(umask 077; echo 'https://outlook.office365.com/owa/calendar/…/calendar.ics' > ~/.config/next-meeting/ics-url)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-meetingYou 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
Run crontab -e and add:
*/5 * * * * PATH=/usr/local/bin:/usr/bin:/bin $HOME/.local/bin/next-meeting >/dev/null 2>&1cron 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.
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.
- Show the meeting title. If your feed has real titles (see step 1),
change the
echoline to:This cuts the title at 25 characters. It also escapesecho "(%F{$colour}${at} ${${summary[1,25]}//\%/%%}, in ${in}%f)"
%, so a title that contains%can't break the prompt. - Change the 4-hour window. Edit
mins < 240. - Refresh more or less often. Change
*/5in 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.
- Nothing shows up. Run
~/.local/bin/next-meetingby 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.