#!/usr/bin/env python3 """Upcoming-birthdays block for daily prep. Reads an org-contacts file for ``:BIRTHDAY: YYYY-MM-DD`` properties, finds the ones whose next occurrence falls within a window (default 30 days) from today, and prints a daily-prep block: name, date, days-away, and — when the birth year is real — the age the person is turning. Many contacts use ``1900`` as a placeholder year when the real one is unknown; those render date-only, no age. Anything within the callout window (default 7 days) is flagged so a gift or plan gets prompted. Pure core: parse_birthdays(text) -> [Birthday(name, month, day, year|None), ...] upcoming(birthdays, today, window=30) -> [Upcoming(name, date, days_away, age|None), ...] format_block(items, window, callout_days=7) -> str CLI: upcoming_birthdays.py [--file PATH] [--today YYYY-MM-DD] [--window N] [--callout N] prints the block on stdout. Default --file is ~/sync/org/contacts.org. """ import argparse import datetime as dt import re import sys from dataclasses import dataclass from pathlib import Path PLACEHOLDER_YEAR = 1900 DEFAULT_CONTACTS = Path.home() / "sync" / "org" / "contacts.org" _HEADING_RE = re.compile(r"^\*+\s+(.*?)\s*$") _TAGS_RE = re.compile(r"\s+:[A-Za-z0-9_@#%:]+:$") _BIRTHDAY_RE = re.compile(r"^\s*:BIRTHDAY:\s*(\d{4})-(\d{2})-(\d{2})\s*$") @dataclass(frozen=True) class Birthday: name: str month: int day: int year: int | None # None when the source used the 1900 placeholder @dataclass(frozen=True) class Upcoming: name: str date: dt.date days_away: int age: int | None # None when the birth year is unknown def _clean_name(heading_text: str) -> str: """Strip a trailing org tag cluster from a heading's text.""" return _TAGS_RE.sub("", heading_text).strip() def parse_birthdays(text: str) -> list[Birthday]: """Extract (name, month, day, year|None) for every contact with a BIRTHDAY. The name is the nearest preceding org heading. A birth year of 1900 is the placeholder org-contacts uses for an unknown year and is returned as None. Malformed birthday lines are ignored. """ birthdays: list[Birthday] = [] current_name: str | None = None for line in text.splitlines(): heading = _HEADING_RE.match(line) if heading: current_name = _clean_name(heading.group(1)) continue bday = _BIRTHDAY_RE.match(line) if bday and current_name: year, month, day = (int(g) for g in bday.groups()) # Guard against a nonsense month/day that regex width still admits. try: dt.date(2000, month, day) except ValueError: continue birthdays.append( Birthday( current_name, month, day, None if year == PLACEHOLDER_YEAR else year, ) ) return birthdays def _next_occurrence(month: int, day: int, today: dt.date) -> dt.date: """First date on/after ``today`` landing on this month/day. A Feb 29 birthday maps to Feb 28 in a non-leap year. """ def on(year: int) -> dt.date: try: return dt.date(year, month, day) except ValueError: # Only Feb 29 can fail here; fall back to Feb 28. return dt.date(year, 2, 28) candidate = on(today.year) if candidate < today: candidate = on(today.year + 1) return candidate def upcoming( birthdays: list[Birthday], today: dt.date, window: int = 30 ) -> list[Upcoming]: """Birthdays whose next occurrence is within ``window`` days, soonest first.""" items: list[Upcoming] = [] for b in birthdays: occ = _next_occurrence(b.month, b.day, today) days = (occ - today).days if 0 <= days <= window: age = None if b.year is None else occ.year - b.year items.append(Upcoming(b.name, occ, days, age)) items.sort(key=lambda u: (u.days_away, u.name)) return items def _days_phrase(days: int) -> str: if days == 0: return "today" if days == 1: return "tomorrow" return f"in {days} days" def format_block(items: list[Upcoming], window: int, callout_days: int = 7) -> str: """Render the daily-prep block. Callout entries (within ``callout_days``) are flagged with a marker and a plan-a-gift nudge.""" if not items: return f"No birthdays in the next {window} days." lines = [f"Upcoming birthdays (next {window} days):"] for u in items: callout = u.days_away <= callout_days marker = "⚠" if callout else "·" date_str = u.date.strftime("%a %b %d") piece = f" {marker} {date_str} — {u.name}" if u.age is not None: piece += f" turns {u.age}" piece += f" ({_days_phrase(u.days_away)})" if callout: piece += " — plan a gift/card" lines.append(piece) return "\n".join(lines) def _parse_today(value: str | None) -> dt.date: if value is None: return dt.date.today() return dt.date.fromisoformat(value) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Print the upcoming-birthdays daily-prep block.") parser.add_argument("--file", type=Path, default=DEFAULT_CONTACTS, help="org-contacts file (default: ~/sync/org/contacts.org)") parser.add_argument("--today", default=None, help="override today's date (YYYY-MM-DD), for testing") parser.add_argument("--window", type=int, default=30, help="look-ahead window in days (default: 30)") parser.add_argument("--callout", type=int, default=7, help="flag birthdays within this many days (default: 7)") args = parser.parse_args(argv) if not args.file.exists(): print(f"contacts file not found: {args.file}", file=sys.stderr) return 1 text = args.file.read_text(encoding="utf-8") items = upcoming(parse_birthdays(text), _parse_today(args.today), args.window) print(format_block(items, args.window, args.callout)) return 0 if __name__ == "__main__": raise SystemExit(main())