aboutsummaryrefslogtreecommitdiff
path: root/claude-templates
diff options
context:
space:
mode:
Diffstat (limited to 'claude-templates')
-rw-r--r--claude-templates/.ai/scripts/tests/test_upcoming_birthdays.py168
-rwxr-xr-xclaude-templates/.ai/scripts/upcoming_birthdays.py182
-rw-r--r--claude-templates/.ai/workflows/daily-prep.org3
3 files changed, 352 insertions, 1 deletions
diff --git a/claude-templates/.ai/scripts/tests/test_upcoming_birthdays.py b/claude-templates/.ai/scripts/tests/test_upcoming_birthdays.py
new file mode 100644
index 0000000..1e15183
--- /dev/null
+++ b/claude-templates/.ai/scripts/tests/test_upcoming_birthdays.py
@@ -0,0 +1,168 @@
+"""Tests for upcoming_birthdays.py — the daily-prep upcoming-birthdays block.
+
+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
+
+Birth year 1900 is the placeholder org-contacts uses when the real year is
+unknown; those entries carry year=None and render date-only (no age).
+"""
+
+import datetime as dt
+import subprocess
+import sys
+from pathlib import Path
+
+SCRIPTS = Path(__file__).parent.parent
+SCRIPT = SCRIPTS / "upcoming_birthdays.py"
+sys.path.insert(0, str(SCRIPTS))
+
+import upcoming_birthdays as ub # noqa: E402
+
+
+# --- parse_birthdays --------------------------------------------------------
+
+def test_parse_reads_name_month_day_year():
+ text = "** Jane Doe\n:PROPERTIES:\n:BIRTHDAY: 1970-08-05\n:END:\n"
+ bdays = ub.parse_birthdays(text)
+ assert bdays == [ub.Birthday("Jane Doe", 8, 5, 1970)]
+
+
+def test_parse_placeholder_year_1900_becomes_none():
+ text = "** John Smith\n:PROPERTIES:\n:BIRTHDAY: 1900-07-14\n:END:\n"
+ bdays = ub.parse_birthdays(text)
+ assert bdays == [ub.Birthday("John Smith", 7, 14, None)]
+
+
+def test_parse_skips_contacts_without_birthday():
+ text = (
+ "** No Birthday\n:PROPERTIES:\n:PHONE: 555\n:END:\n"
+ "** Has Birthday\n:PROPERTIES:\n:BIRTHDAY: 1990-03-02\n:END:\n"
+ )
+ bdays = ub.parse_birthdays(text)
+ assert [b.name for b in bdays] == ["Has Birthday"]
+
+
+def test_parse_strips_heading_stars_and_tags():
+ text = "*** Bob Jones :friend:\n:PROPERTIES:\n:BIRTHDAY: 1980-01-01\n:END:\n"
+ bdays = ub.parse_birthdays(text)
+ assert bdays[0].name == "Bob Jones"
+
+
+def test_parse_ignores_malformed_birthday_lines():
+ text = "** Bad Date\n:PROPERTIES:\n:BIRTHDAY: not-a-date\n:END:\n"
+ assert ub.parse_birthdays(text) == []
+
+
+# --- upcoming ---------------------------------------------------------------
+
+TODAY = dt.date(2026, 7, 18)
+
+
+def test_upcoming_birthday_today_is_zero_days():
+ bdays = [ub.Birthday("Today Person", 7, 18, 1990)]
+ got = ub.upcoming(bdays, TODAY)
+ assert got[0].days_away == 0
+ assert got[0].date == dt.date(2026, 7, 18)
+
+
+def test_upcoming_includes_within_window():
+ bdays = [ub.Birthday("Soon", 7, 23, 1990)]
+ got = ub.upcoming(bdays, TODAY, window=30)
+ assert got[0].days_away == 5
+
+
+def test_upcoming_excludes_beyond_window():
+ bdays = [ub.Birthday("Far", 9, 1, 1990)] # 45 days out
+ assert ub.upcoming(bdays, TODAY, window=30) == []
+
+
+def test_upcoming_boundary_day_30_included_day_31_excluded():
+ on = [ub.Birthday("On", 8, 17, 1990)] # exactly 30 days
+ off = [ub.Birthday("Off", 8, 18, 1990)] # 31 days
+ assert ub.upcoming(on, TODAY, window=30)[0].days_away == 30
+ assert ub.upcoming(off, TODAY, window=30) == []
+
+
+def test_upcoming_uses_next_year_when_this_years_passed():
+ # today is 2026-07-18; a Jan 5 birthday recurs on 2027-01-05
+ today = dt.date(2026, 12, 27)
+ bdays = [ub.Birthday("New Year", 1, 5, 1990)]
+ got = ub.upcoming(bdays, today, window=30)
+ assert got[0].date == dt.date(2027, 1, 5)
+ assert got[0].days_away == 9
+
+
+def test_upcoming_age_is_occurrence_year_minus_birth_year():
+ bdays = [ub.Birthday("Ager", 7, 23, 1970)]
+ got = ub.upcoming(bdays, TODAY)
+ assert got[0].age == 56 # 2026 - 1970
+
+
+def test_upcoming_age_none_for_placeholder():
+ bdays = [ub.Birthday("Placeholder", 7, 23, None)]
+ got = ub.upcoming(bdays, TODAY)
+ assert got[0].age is None
+
+
+def test_upcoming_sorted_by_days_away():
+ bdays = [
+ ub.Birthday("Later", 8, 10, 1990),
+ ub.Birthday("Sooner", 7, 20, 1990),
+ ]
+ got = ub.upcoming(bdays, TODAY)
+ assert [u.name for u in got] == ["Sooner", "Later"]
+
+
+def test_upcoming_leap_day_maps_to_feb_28_in_non_leap_year():
+ today = dt.date(2027, 2, 1) # 2027 is not a leap year
+ bdays = [ub.Birthday("Leapling", 2, 29, 2000)]
+ got = ub.upcoming(bdays, today, window=30)
+ assert got[0].date == dt.date(2027, 2, 28)
+
+
+# --- format_block -----------------------------------------------------------
+
+def test_format_block_empty_reports_none():
+ out = ub.format_block([], window=30)
+ assert "No birthdays" in out
+
+
+def test_format_block_callout_marks_within_seven_days():
+ items = [ub.Upcoming("Soon", dt.date(2026, 7, 22), 4, 40)]
+ out = ub.format_block(items, window=30, callout_days=7)
+ assert "⚠" in out
+ assert "Soon" in out
+ assert "40" in out # age shown
+
+
+def test_format_block_beyond_callout_is_not_flagged():
+ items = [ub.Upcoming("Later", dt.date(2026, 8, 10), 23, 30)]
+ out = ub.format_block(items, window=30, callout_days=7)
+ assert "⚠" not in out
+
+
+def test_format_block_placeholder_shows_date_only_no_age():
+ items = [ub.Upcoming("NoYear", dt.date(2026, 7, 25), 7, None)]
+ out = ub.format_block(items, window=30)
+ assert "NoYear" in out
+ assert "turns" not in out
+
+
+# --- CLI --------------------------------------------------------------------
+
+def test_cli_runs_against_a_fixture_file(tmp_path):
+ contacts = tmp_path / "contacts.org"
+ contacts.write_text(
+ "** Alice\n:PROPERTIES:\n:BIRTHDAY: 1990-07-20\n:END:\n"
+ "** Bob\n:PROPERTIES:\n:BIRTHDAY: 1900-12-01\n:END:\n"
+ )
+ res = subprocess.run(
+ [sys.executable, str(SCRIPT), "--file", str(contacts),
+ "--today", "2026-07-18", "--window", "30"],
+ capture_output=True, text=True,
+ )
+ assert res.returncode == 0
+ assert "Alice" in res.stdout
+ assert "Bob" not in res.stdout # Dec 1 is outside the 30-day window
diff --git a/claude-templates/.ai/scripts/upcoming_birthdays.py b/claude-templates/.ai/scripts/upcoming_birthdays.py
new file mode 100755
index 0000000..d3f30c0
--- /dev/null
+++ b/claude-templates/.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())
diff --git a/claude-templates/.ai/workflows/daily-prep.org b/claude-templates/.ai/workflows/daily-prep.org
index c708410..3f21214 100644
--- a/claude-templates/.ai/workflows/daily-prep.org
+++ b/claude-templates/.ai/workflows/daily-prep.org
@@ -74,7 +74,7 @@ The separate =* Standup Briefs= and =* Upcoming Deadlines= sections are *retired
Items that frame the day. Four standing items are *always* present, plus the look-ahead:
1. *Meeting-density framing.* One line on the day's shape, e.g. "Meeting-dense morning: 09:00 team discussion, 10:00 standup, 11:00 general standup. Real focus time only opens at noon."
-2. *Calendar events from BOTH calendars* (work + personal): birthdays, holidays, anniversaries, vacations, trips, big events. "Your trip begins Friday." "It's <person>'s birthday today."
+2. *Calendar events from BOTH calendars* (work + personal): birthdays, holidays, anniversaries, vacations, trips, big events. "Your trip begins Friday." "It's <person>'s birthday today." Fold in the =upcoming_birthdays.py= block from Phase A (source 8) for contact birthdays the calendar doesn't carry; keep its callout entries (within 7 days) so a gift or plan gets prompted.
3. *Reminders due, imminently due, or past trigger* — from notes.org Active Reminders plus scheduled/deadline tasks. A reminder tied to a rescheduled meeting reports against the new date.
4. *Requested metrics* — a slot for any metric Craig has asked to track in the daily prep. Render the slot only when a metric is active; none are active by default. (Metric design is a separate discussion — don't invent metrics.)
5. *5-Day Look-Ahead* — one day per line, format =Fri 12:= / =Mon 15:=, including clear days marked =clear=. Built by Phase 1's forward scan with the invite quick-read and decline gate applied.
@@ -192,6 +192,7 @@ Pull every source in a *single batch of parallel tool calls*:
5. Pull the project tracker's view of Craig's plate (assigned issues, items in review, blocked items) where the project has one.
6. List + read the *previous* prep doc. Glob =daily-prep/*-daily-prep.org=, sort by date, take the file *before* the one the root =daily-prep.org= symlink resolves to. If the symlink doesn't resolve yet, take the most recent file. The standup lookback anchors on this file's date.
7. Read the most recent =.ai/sessions/= summary (for the standup brief's lookback).
+8. Run =.ai/scripts/upcoming_birthdays.py= (reads =~/sync/org/contacts.org=). Its block feeds the Heads-Up birthday line — contacts carry birthdays the calendar doesn't, and anything inside 7 days comes back flagged so a gift/plan gets prompted.
This fetch *is* the live calendar read for build time. In Update mode, re-run the calendar fetches — never reuse the build-time snapshot.