284 lines
10 KiB
Python
284 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Daily digest for Andy — checks birthdays, holidays, notable weather.
|
|
Silent exit (no output) if nothing noteworthy. Meant for no_agent=True cron."""
|
|
|
|
import json
|
|
import urllib.request
|
|
import urllib.error
|
|
from datetime import date, timedelta
|
|
import sys
|
|
|
|
# ─── CONFIG ──────────────────────────────────────────────────────────────────
|
|
|
|
BIRTHDAYS = {
|
|
"Muna": (8, 2), # mom
|
|
"Steve": (3, 26), # dad
|
|
"Lisa": (9, 30), # sister (1985)
|
|
"Donna": (1, 19), # wife (1977)
|
|
"Calvin": (10, 9), # son (2013)
|
|
}
|
|
|
|
ANNIVERSARIES = {
|
|
"Wedding 🥂": (8, 20), # married 8/20/2011
|
|
}
|
|
|
|
# ─── SCHOOL (TCS 2026-2027) ───────────────────────────────────────────────────
|
|
# Format: (month, day, label, lead_days)
|
|
# lead_days = how far in advance to start notifying
|
|
|
|
SCHOOL_EVENTS = [
|
|
# (month, day, label, lead_days)
|
|
# lead_days = how far in advance to start notifying
|
|
(8, 10, "📚 Teacher Work Days begin (school starts Aug 17)", 1),
|
|
(8, 17, "📚 **First Day of School** — TCS 2026-27!", 20),
|
|
(9, 7, "🏖️ Labor Day — no school", 1),
|
|
(10, 2, "📋 Progress Reports issued", 7),
|
|
(10, 6, "👩🏫 Parent-Teacher Conferences begin", 7),
|
|
(10, 12, "🏖️ Columbus Day — no school", 1),
|
|
(11, 6, "🏖️ Veterans Day observed — no school", 1),
|
|
(11, 25, "🦃 Thanksgiving Break begins — no school", 7),
|
|
(12, 18, "📚 End of 1st Semester", 7),
|
|
(12, 21, "🎄 Winter Break begins — no school", 7),
|
|
(1, 8, "📋 Progress Reports issued", 7),
|
|
(1, 18, "🏖️ MLK Day — no school", 1),
|
|
(2, 15, "🏖️ Presidents' Day — no school", 1),
|
|
(3, 4, "📋 Progress Reports issued", 7),
|
|
(3, 9, "👩🏫 Parent-Teacher Conferences begin", 7),
|
|
(4, 2, "🌸 Spring Break begins — no school", 7),
|
|
(4, 6, "🚌 School Trip", 7),
|
|
(4, 19, "🌸 Spring Break continues — no school", 7),
|
|
(5, 28, "🎓 **Last Day of School!**", 20),
|
|
(5, 31, "🏖️ Memorial Day — no school", 1),
|
|
(6, 4, "📋 Progress Reports issued", 7),
|
|
]
|
|
|
|
# Beaufort, SC
|
|
LAT, LON = "32.43", "-80.67"
|
|
|
|
# ─── HELPERS ─────────────────────────────────────────────────────────────────
|
|
|
|
def nth_weekday(year, month, weekday, n):
|
|
"""Return date of nth weekday in month (0=Mon .. 6=Sun)."""
|
|
first = date(year, month, 1)
|
|
offset = (weekday - first.weekday()) % 7
|
|
return first + timedelta(days=offset + 7 * (n - 1))
|
|
|
|
def father_sunday(year):
|
|
"""Third Sunday of June."""
|
|
return nth_weekday(year, 6, 6, 3)
|
|
|
|
def mother_sunday(year):
|
|
"""Second Sunday of May."""
|
|
return nth_weekday(year, 5, 6, 2)
|
|
|
|
def fetch_json(url, timeout=10):
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "Hermes-Digest/1.0"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return json.loads(resp.read())
|
|
except Exception as e:
|
|
return None
|
|
|
|
def days_until_birthday(month, day, today):
|
|
"""Return (days_until, date_obj). Handles past birthdays by wrapping to next year."""
|
|
try:
|
|
this_year = date(today.year, month, day)
|
|
except ValueError:
|
|
return None, None
|
|
diff = (this_year - today).days
|
|
if diff >= 0:
|
|
return diff, this_year
|
|
# Already past — check next year
|
|
try:
|
|
next_year = date(today.year + 1, month, day)
|
|
return (next_year - today).days, next_year
|
|
except ValueError:
|
|
return None, None
|
|
|
|
# ─── WEATHER ─────────────────────────────────────────────────────────────────
|
|
|
|
def check_alerts():
|
|
"""Check NWS for active severe weather alerts near Beaufort, SC."""
|
|
data = fetch_json(f"https://api.weather.gov/alerts/active?point={LAT},{LON}")
|
|
if not data:
|
|
return None
|
|
features = data.get("features", [])
|
|
notable = []
|
|
for f in features:
|
|
p = f.get("properties", {})
|
|
sev = p.get("severity", "")
|
|
event = p.get("event", "")
|
|
headline = p.get("headline", "")
|
|
if sev in ("Severe", "Extreme"):
|
|
notable.append(f"⚠️ {event}: {headline}")
|
|
elif sev == "Moderate":
|
|
notable.append(f"⚠️ {event}: {headline}")
|
|
return notable if notable else None
|
|
|
|
def check_forecast():
|
|
"""Check NWS forecast for notably bad conditions today/tomorrow."""
|
|
points = fetch_json(f"https://api.weather.gov/points/{LAT},{LON}")
|
|
if not points:
|
|
return None
|
|
forecast_url = points.get("properties", {}).get("forecast")
|
|
if not forecast_url:
|
|
return None
|
|
data = fetch_json(forecast_url)
|
|
if not data:
|
|
return None
|
|
periods = data.get("properties", {}).get("periods", [])
|
|
|
|
today_str = date.today().isoformat()
|
|
tomorrow_str = (date.today() + timedelta(days=1)).isoformat()
|
|
notes = []
|
|
|
|
SEVERE = [
|
|
"hurricane", "tropical storm", "severe thunderstorm", "tornado",
|
|
"blizzard", "ice storm", "winter storm", "flash flood",
|
|
]
|
|
|
|
for p in periods[:4]:
|
|
start = (p.get("startTime") or "")[:10]
|
|
if start not in (today_str, tomorrow_str):
|
|
continue
|
|
temp = p.get("temperature", 70)
|
|
wind = (p.get("windSpeed") or "").lower()
|
|
short = p.get("shortForecast", "")
|
|
|
|
if temp >= 100:
|
|
notes.append(f"🌡️ Extreme heat: {temp}°F — {short}")
|
|
elif temp <= 20:
|
|
notes.append(f"🥶 Extreme cold: {temp}°F — {short}")
|
|
|
|
for kw in SEVERE:
|
|
if kw in short.lower():
|
|
notes.append(f"🌩️ {short}")
|
|
break
|
|
|
|
# High wind (grab first number from wind string)
|
|
nums = [c for c in wind.split()[0] if c.isdigit()] if wind else []
|
|
if nums:
|
|
try:
|
|
if int("".join(nums)) >= 30:
|
|
notes.append(f"💨 High wind: {p.get('windSpeed', '')}")
|
|
except ValueError:
|
|
pass
|
|
|
|
return notes if notes else None
|
|
|
|
# ─── MAIN ────────────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
today = date.today()
|
|
lines = []
|
|
|
|
# ── Birthdays ──
|
|
upcoming = []
|
|
for name, (m, d) in BIRTHDAYS.items():
|
|
diff, dt = days_until_birthday(m, d, today)
|
|
if diff is not None and 0 <= diff <= 20:
|
|
upcoming.append((name, diff, dt))
|
|
|
|
if upcoming:
|
|
lines.append("🎂 **Upcoming Birthdays:**")
|
|
for name, diff, dt in sorted(upcoming, key=lambda x: x[1]):
|
|
if diff == 0:
|
|
lines.append(f" • **{name}** — TODAY! 🎉")
|
|
elif diff == 1:
|
|
lines.append(f" • **{name}** — tomorrow!")
|
|
else:
|
|
lines.append(f" • **{name}** — in {diff} days ({dt.strftime('%B %d')})")
|
|
lines.append("")
|
|
|
|
# ── Anniversaries ──
|
|
anniv_upcoming = []
|
|
for label, (m, d) in ANNIVERSARIES.items():
|
|
diff, dt = days_until_birthday(m, d, today)
|
|
if diff is not None and 0 <= diff <= 20:
|
|
anniv_upcoming.append((label, diff, dt))
|
|
|
|
if anniv_upcoming:
|
|
lines.append("💍 **Upcoming Anniversaries:**")
|
|
for label, diff, dt in sorted(anniv_upcoming, key=lambda x: x[1]):
|
|
if diff == 0:
|
|
lines.append(f" • **{label}** — TODAY! 🎉")
|
|
elif diff == 1:
|
|
lines.append(f" • **{label}** — tomorrow!")
|
|
else:
|
|
lines.append(f" • **{label}** — in {diff} days ({dt.strftime('%B %d')})")
|
|
lines.append("")
|
|
|
|
# ── Holidays ──
|
|
events = []
|
|
for label, fn in [("Father's Day 👔", father_sunday), ("Mother's Day 🌸", mother_sunday)]:
|
|
d = fn(today.year)
|
|
diff = (d - today).days
|
|
if 0 <= diff <= 20:
|
|
events.append((label, diff, d))
|
|
elif diff < 0:
|
|
d_next = fn(today.year + 1)
|
|
diff = (d_next - today).days
|
|
if diff <= 20:
|
|
events.append((label, diff, d_next))
|
|
|
|
if events:
|
|
lines.append("📅 **Upcoming Events:**")
|
|
for label, diff, dt in sorted(events, key=lambda x: x[1]):
|
|
if diff == 0:
|
|
lines.append(f" • **{label}** — TODAY!")
|
|
elif diff == 1:
|
|
lines.append(f" • **{label}** — tomorrow!")
|
|
else:
|
|
lines.append(f" • **{label}** — in {diff} days ({dt.strftime('%B %d')})")
|
|
lines.append("")
|
|
|
|
# ── Weather alerts ──
|
|
alerts = check_alerts()
|
|
if alerts:
|
|
lines.append("🚨 **Weather Alerts:**")
|
|
for a in alerts:
|
|
lines.append(f" {a}")
|
|
lines.append("")
|
|
|
|
# ── Notable forecast ──
|
|
forecast_notes = check_forecast()
|
|
if forecast_notes:
|
|
lines.append("🌤️ **Notable Weather:**")
|
|
for n in forecast_notes:
|
|
lines.append(f" {n}")
|
|
lines.append("")
|
|
|
|
# ── School events ──
|
|
school_upcoming = []
|
|
school_year = 2026 # School year starts in Aug 2026
|
|
for month, day, label, lead_days in SCHOOL_EVENTS:
|
|
# School year spans Aug 2026 - Jun 2027
|
|
ev_year = 2026 if month >= 8 else 2027
|
|
try:
|
|
ev_date = date(ev_year, month, day)
|
|
except ValueError:
|
|
continue
|
|
diff = (ev_date - today).days
|
|
if 0 <= diff <= lead_days:
|
|
school_upcoming.append((label, diff, ev_date))
|
|
|
|
if school_upcoming:
|
|
lines.append("🏫 **School Calendar:**")
|
|
for label, diff, dt in sorted(school_upcoming, key=lambda x: x[1]):
|
|
if diff == 0:
|
|
lines.append(f" • {label} — **TODAY!**")
|
|
elif diff == 1:
|
|
lines.append(f" • {label} — tomorrow")
|
|
else:
|
|
lines.append(f" • {label} — in {diff} days ({dt.strftime('%B %d')})")
|
|
lines.append("")
|
|
|
|
# ── Output ──
|
|
if lines:
|
|
header = f"☀️ **Daily Digest** — {today.strftime('%A, %B %d, %Y')}\n\n"
|
|
footer = "\n— Hermes"
|
|
print(header + "\n".join(lines).strip() + footer)
|
|
# else: silent exit — nothing worth reporting
|
|
|
|
if __name__ == "__main__":
|
|
main() |