#!/usr/bin/env python3 """Race Radar — static page generator. Reads a local JSON file of upcoming endurance races and renders a single self-contained marketing/SEO page that funnels visitors to an AI training plan coach. Zero dependencies: python3 stdlib only. Atomic writes. Every JSON-derived string is HTML-escaped before it reaches the page. """ import argparse import datetime as dt import html import json import os import sys import tempfile TARGET_SITE = "https://aitrainingplan.app/" GUIDE_BASE = "https://aitrainingplan.app/en/learn/guides/" GUIDE_703 = GUIDE_BASE + "half-ironman-training-plan/" GUIDE_1406 = GUIDE_BASE + "ironman-training-plan/" PROMPT_BUILDER = "https://aitrainingplan.app/en/tools/prompt-builder/" def days_until(race_date: str, today: dt.date) -> int: """Calendar-day difference; race_date is treated as a local calendar date.""" race = dt.date.fromisoformat(race_date) return (race - today).days def window_status(days_left: int, plan_weeks: int) -> tuple[str, str]: if days_left < 0: return ("past", "") if days_left == 0: return ("race", "Race day.") if 1 <= days_left <= 7: return ("race", "Race week — taper, hydrate, trust the block.") weeks = days_left // 7 if weeks >= plan_weeks: return ("full", f"A full {plan_weeks}-week plan still fits. Start this week.") # integer-exact ceil(0.6*plan_weeks): 0.6*20 floats to 12.000000000000002 if weeks >= (6 * plan_weeks + 9) // 10: return ("tight", f"{weeks} of the ideal {plan_weeks} weeks left — compressed but coachable.") return ("short", f"{weeks} weeks left — target a tune-up distance or lock the next edition.") def fmt_date(d: dt.date) -> str: return f"{d.strftime('%a')} {d.day} {d.strftime('%b')} {d.year}" def guide_url(sport: str) -> str: if sport == "703": return GUIDE_703 if sport == "140.6": return GUIDE_1406 return GUIDE_BASE def safe_url(url: str) -> str: url = str(url or "") return url if url.startswith("https://") else "" def build_race_cards(raw_races: list, today: dt.date) -> list: """Normalize + escape every field, drop past races, sort by date asc.""" cards = [] for r in raw_races: if not isinstance(r, dict): continue date_s = str(r.get("date", "")) try: days_left = days_until(date_s, today) race_date = dt.date.fromisoformat(date_s) except (ValueError, TypeError): continue # unparseable entry — skip it, don't blow up the whole page if days_left < 0: continue # renderer drops past races try: plan_weeks = int(r.get("plan_weeks", 12)) except (ValueError, TypeError): plan_weeks = 12 css_class, message = window_status(days_left, plan_weeks) weeks, rem_days = divmod(days_left, 7) sport = html.escape(str(r.get("sport", ""))) cards.append({ "name": html.escape(str(r.get("name", "Untitled race"))), "city": html.escape(str(r.get("city", ""))), "flag": html.escape(str(r.get("country_flag", ""))), "distance_label": html.escape(str(r.get("distance_label", ""))), "sport_raw": str(r.get("sport", "")), "sport": sport, "url": html.escape(safe_url(r.get("url", ""))), "date_iso": date_s, "date_text": fmt_date(race_date), "days_left": days_left, "weeks": weeks, "rem_days": rem_days, "plan_weeks": plan_weeks, "css_class": css_class, "message": message, }) cards.sort(key=lambda c: c["date_iso"]) return cards CSS = """ :root{color-scheme:dark;} *{box-sizing:border-box;margin:0;padding:0;} body{background:#0b0f14;color:#e8ecef;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;line-height:1.5;padding:0 1.25rem 3rem;} a{color:#c8f55a;} main{max-width:1100px;margin:0 auto;} .hero{padding:4rem 0 2.5rem;text-align:center;} .kicker{letter-spacing:.2em;font-size:.8rem;font-weight:700;color:#c8f55a;margin-bottom:1rem;} h1{font-size:clamp(2rem,5vw,3.2rem);font-weight:800;max-width:18ch;margin:0 auto 1rem;} .subline{color:#9aa5ad;font-size:1.05rem;margin-bottom:2rem;} .btn{display:inline-block;background:#c8f55a;color:#0b0f14;font-weight:700;text-decoration:none;padding:.9rem 1.6rem;border-radius:.6rem;transition:transform .15s ease;} .btn:hover{transform:translateY(-2px);} .btn.secondary{background:transparent;color:#e8ecef;border:1px solid #2a333c;} .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(340px,1fr));gap:1.25rem;margin:2rem 0;} .card{background:#121820;border:1px solid #1e2731;border-top:3px solid #2a333c;border-radius:.9rem;padding:1.5rem;display:flex;flex-direction:column;gap:.6rem;} .card.full{border-top-color:#c8f55a;} .card.tight{border-top-color:#f5c542;} .card.race{border-top-color:#ff6b6b;} .card.short{border-top-color:#7dd3fc;} .card-head{display:flex;align-items:baseline;gap:.6rem;} .flag{font-size:1.4rem;} .card-head h2{font-size:1.15rem;font-weight:700;} .meta{color:#9aa5ad;font-size:.9rem;} .days-num{font-size:2.1rem;font-weight:800;color:#e8ecef;} .days-weeks{color:#9aa5ad;font-size:.85rem;} .badge{display:inline-block;padding:.35em .8em;border-radius:999px;font-weight:600;font-size:.85rem;border:1px solid currentColor;width:fit-content;} .badge-full{color:#c8f55a;} .badge-tight{color:#f5c542;} .badge-race{color:#ff6b6b;} .badge-short{color:#7dd3fc;} .distance{color:#9aa5ad;font-size:.9rem;} .links{display:flex;gap:1rem;flex-wrap:wrap;font-size:.9rem;margin-top:.3rem;} .empty{color:#9aa5ad;padding:2rem 0;} .note{color:#6b747c;font-size:.85rem;border-top:1px solid #1e2731;border-bottom:1px solid #1e2731;padding:1.25rem 0;margin:2rem 0;} .cta-block{text-align:center;padding:3rem 0;} .cta-block p{font-size:1.3rem;font-weight:700;margin-bottom:1.5rem;max-width:24ch;margin-left:auto;margin-right:auto;} .cta-buttons{display:flex;gap:1rem;justify-content:center;flex-wrap:wrap;} footer{color:#6b747c;font-size:.8rem;text-align:center;padding-top:2rem;} footer a{color:#6b747c;text-decoration:underline;} """ def card_html(c: dict) -> str: site_link = "" if c["url"]: site_link = f'official site →' return f"""
{c['flag']}

{c['name']}

{c['city']} · {c['date_text']}

{c['days_left']} day{'s' if c['days_left'] != 1 else ''}

= {c['weeks']} week{'s' if c['weeks'] != 1 else ''} {c['rem_days']} day{'s' if c['rem_days'] != 1 else ''}

{c['message']}

{c['distance_label']}

""" JS = """ (function(){ // calendar-day count in the VISITOR's local calendar, DST-proof: // both endpoints are Y/M/D projected to UTC midnight, so the diff is an // exact multiple of 86400000. Never new Date("YYYY-MM-DD") — that parses // as UTC midnight and shifts a day for visitors west of Greenwich. var now=new Date(); var todayUTC=Date.UTC(now.getFullYear(),now.getMonth(),now.getDate()); var cards=document.querySelectorAll('[data-date]'); for(var i=0;i0&&days<14){ var hours=Math.round((new Date(+p[0],+p[1]-1,+p[2])-now)/3600000); if(hours>0)label+=' \\u00b7 ~'+hours+'h'; } var num=c.querySelector('.days-num'); if(num)num.textContent=label; var wk=c.querySelector('.days-weeks'); if(wk){ var w=Math.floor(days/7),d=days%7; wk.textContent='= '+w+' week'+(w===1?'':'s')+' '+d+' day'+(d===1?'':'s'); } } })(); """ def render(cards: list, today: dt.date) -> str: count = len(cards) generated = dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d %H:%M UTC") if cards: grid = "\n".join(card_html(c) for c in cards) else: grid = '

No upcoming start lines tracked right now — check back soon.

' return f""" Race Radar — how many training weeks until your start line?

RACE RADAR

Your start line is closer than you think.

As of {fmt_date(today)} · {count} start line{'s' if count != 1 else ''} tracked · countdowns update daily

Get an AI plan that fits the weeks you have left →
{grid}

Dates verified against official race sites. Regenerated automatically — this page can't go stale.

However many weeks you have, there's a right plan for them.

Generated {generated} · refreshes daily · view the generator source · Independent concept page for AiTrainingPlan — not affiliated.
""" def atomic_write(path: str, content: str) -> None: d = os.path.dirname(os.path.abspath(path)) or "." fd, tmp = tempfile.mkstemp(dir=d, suffix=".tmp") try: with os.fdopen(fd, "w", encoding="utf-8") as f: f.write(content) os.replace(tmp, path) # atomic: a failed run can never blank the live page finally: if os.path.exists(tmp): os.unlink(tmp) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--out", required=True) ap.add_argument("--races", default="races.json", help="path to races JSON array") ap.add_argument("--date", help="YYYY-MM-DD override for testing edge days") a = ap.parse_args() today = dt.date.fromisoformat(a.date) if a.date else dt.datetime.now(dt.timezone.utc).date() try: with open(a.races, encoding="utf-8") as f: raw = json.load(f) if not isinstance(raw, list): raise ValueError("races file must contain a JSON array") except Exception as e: print(f"ERROR: could not load races file '{a.races}': {e}", file=sys.stderr) return 1 # never write — last good page survives cards = build_race_cards(raw, today) atomic_write(a.out, render(cards, today)) print(f"OK wrote {a.out} ({len(cards)} races rendered)") return 0 if __name__ == "__main__": sys.exit(main())