aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts/upcoming_birthdays.py
blob: d3f30c0385f402e1fb29a797ce62a28ac1103268 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
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())