diff options
| author | Craig Jennings <c@cjennings.net> | 2026-07-18 18:22:32 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-07-18 18:22:32 -0500 |
| commit | 80ebb74e4f3f35c2204ba5fee8c48bf5d18f0559 (patch) | |
| tree | c2dec1849876aaab4384e2607321150351400e19 /.ai/scripts/upcoming_birthdays.py | |
| parent | 6e487146470381424e520fd00dbe155856656e3f (diff) | |
| download | rulesets-80ebb74e4f3f35c2204ba5fee8c48bf5d18f0559.tar.gz rulesets-80ebb74e4f3f35c2204ba5fee8c48bf5d18f0559.zip | |
feat(daily-prep): add upcoming-birthdays block from contacts.org
New stdlib-only upcoming_birthdays.py reads ~/sync/org/contacts.org for :BIRTHDAY: properties and prints a daily-prep block: name, date, days-away, and the age turned when the birth year is known (1900 is the org-contacts unknown-year placeholder, rendered date-only). Birthdays inside 7 days come back flagged so a gift or plan gets prompted. 19 pytest cases cover the window boundaries, leap-day fallback to Feb 28, the placeholder year, and the CLI.
daily-prep gains a Phase A source that runs the script and a Heads-Up line that folds in the block, so contact birthdays the calendar doesn't carry still surface.
Built and tested in the home project. Promoted here because the daily-prep hook lives in this synced workflow and the contacts file is present on every machine.
Diffstat (limited to '.ai/scripts/upcoming_birthdays.py')
| -rwxr-xr-x | .ai/scripts/upcoming_birthdays.py | 182 |
1 files changed, 182 insertions, 0 deletions
diff --git a/.ai/scripts/upcoming_birthdays.py b/.ai/scripts/upcoming_birthdays.py new file mode 100755 index 0000000..d3f30c0 --- /dev/null +++ b/.ai/scripts/upcoming_birthdays.py @@ -0,0 +1,182 @@ +#!/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()) |
