scripts/ts03-render
#!/usr/bin/env python3
"""ts03-render: turn Claude's JSON answer into table, text, json, yaml or html.
Usage: claude -p "...reply as JSON..." | ts03-render --format html
"""
import argparse, datetime, html, json, os, re, subprocess, sys
p = argparse.ArgumentParser()
p.add_argument("--format", default="table",
choices=["table", "text", "json", "yaml", "html"])
p.add_argument("--outdir", default=os.path.expanduser("~/ts03-reports"))
p.add_argument("--no-open", action="store_true")
a = p.parse_args()
raw = sys.stdin.read().strip()
m = re.search(r"\{.*\}", raw, re.S)
if not m:
sys.exit("ts03-render: no JSON in Claude's reply:\n\n" + raw)
try:
d = json.loads(m.group(0))
except json.JSONDecodeError as e:
sys.exit(f"ts03-render: invalid JSON ({e}):\n\n{raw}")
items = d.get("items") or []
def sort_key(i):
return (i.get("due_date") or "9999-12-31", i.get("time") or "")
items.sort(key=sort_key)
def days(i):
v = i.get("days_left")
return "-" if v in (None, "") else str(v)
def fmt_date(s):
try:
return datetime.date.fromisoformat(s).strftime("%d-%b-%Y")
except Exception:
return s or "TBD"
groups = {}
for i in items:
groups.setdefault(i.get("subject") or "Other", []).append(i)
title = d.get("title") or "TS-03 Tracker"
answer = d.get("answer") or ""
notes = d.get("notes") or []
gen = d.get("generated_on") or datetime.date.today().isoformat()
# ---------------------------------------------------------------- table
if a.format == "table":
cols = ["Due", "Days", "Subject", "Type", "Title", "Status"]
rows = [[fmt_date(i.get("due_date")), days(i), i.get("subject", ""),
i.get("type", ""), (i.get("title", "") or "")[:48], i.get("status", "")]
for i in items]
w = [max([len(c)] + [len(r[k]) for r in rows]) for k, c in enumerate(cols)]
line = " ".join
print(f"\n{title} ({fmt_date(gen)})\n")
if answer:
print(answer + "\n")
if rows:
print(line(c.ljust(w[k]) for k, c in enumerate(cols)))
print(line("-" * x for x in w))
for r in rows:
print(line(v.ljust(w[k]) for k, v in enumerate(r)))
else:
print("No matching items.")
for n in notes:
print(f"\n• {n}")
print()
# ---------------------------------------------------------------- text
elif a.format == "text":
print(f"\n{title} ({fmt_date(gen)})")
if answer:
print(f"\n{answer}")
for subj, its in groups.items():
print(f"\n{subj}")
for i in its:
t = f" {i['time']}" if i.get("time") else ""
print(f" - {fmt_date(i.get('due_date'))}{t} {i.get('type','')}: "
f"{i.get('title','')} ({days(i)} days left, {i.get('status','')})")
if not items:
print("\nNo matching items.")
for n in notes:
print(f"\n• {n}")
print()
# ---------------------------------------------------------------- json
elif a.format == "json":
d["items"] = items
print(json.dumps(d, indent=2, ensure_ascii=False))
# ---------------------------------------------------------------- yaml
elif a.format == "yaml":
def q(v):
if v is None:
return "null"
if isinstance(v, (int, float)):
return str(v)
return json.dumps(str(v), ensure_ascii=False)
out = [f"title: {q(title)}", f"generated_on: {q(gen)}", f"answer: {q(answer)}", "items:"]
if not items:
out[-1] += " []"
for i in items:
first = True
for k in ["subject", "type", "title", "due_date", "time",
"status", "days_left", "points", "notes"]:
out.append(f" {'- ' if first else ' '}{k}: {q(i.get(k))}")
first = False
out.append("notes:" + (" []" if not notes else ""))
out += [f" - {q(n)}" for n in notes]
print("\n".join(out))
# ---------------------------------------------------------------- html
else:
COLORS = {"fovl": "#5B8DEF", "vision": "#5B8DEF", "gpu": "#5EBFA0",
"ethics": "#9C7FD6", "innovation": "#F4A261", "ip management": "#F4A261",
"advanced": "#F28B82"}
def color(s):
s = (s or "").lower()
return next((c for k, c in COLORS.items() if k in s), "#8a8f98")
def badge(i):
v = i.get("days_left")
try:
v = int(v)
except Exception:
return '<span class="badge muted">TBD</span>'
cls = "red" if v <= 3 else "amber" if v <= 7 else "green"
if v < 0:
return f'<span class="badge red">{-v}d overdue</span>'
return f'<span class="badge {cls}">{v}d left</span>'
e = html.escape
total = len(items)
urgent = sum(1 for i in items if str(i.get("days_left", "")).lstrip("-").isdigit()
and int(i["days_left"]) <= 3)
nxt = items[0] if items else None
cards = []
for subj, its in groups.items():
rows = "".join(
f"<tr><td class='due'>{e(fmt_date(i.get('due_date')))}"
f"<small>{e(i.get('time') or '')}</small></td>"
f"<td><div class='t'>{e(i.get('title') or '')}</div>"
f"<small>{e(i.get('type') or '')}"
f"{' · ' + e(str(i.get('points'))) if i.get('points') else ''}"
f"{' · ' + e(i.get('notes')) if i.get('notes') else ''}</small></td>"
f"<td class='st'>{e(i.get('status') or '')}</td><td>{badge(i)}</td></tr>"
for i in its)
cards.append(
f"<section class='card' style='--c:{color(subj)}'>"
f"<h2><span class='dot'></span>{e(subj)}<span class='n'>{len(its)}</span></h2>"
f"<table>{rows}</table></section>")
doc = f"""<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{e(title)}</title>
<style>
:root{{--bg:#f6f7f9;--fg:#1d2127;--mut:#6b7280;--card:#fff;--line:#e5e7eb}}
@media (prefers-color-scheme:dark){{:root{{--bg:#0f1115;--fg:#e8eaed;--mut:#9aa0a6;--card:#181b21;--line:#2a2e36}}}}
*{{box-sizing:border-box}}body{{margin:0;background:var(--bg);color:var(--fg);
font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}}
main{{max-width:880px;margin:0 auto;padding:28px 18px 48px}}
header h1{{margin:0;font-size:24px}}header p{{margin:4px 0 0;color:var(--mut)}}
.stats{{display:grid;grid-template-columns:repeat(3,1fr);gap:10px;margin:20px 0}}
.stat{{background:var(--card);border:1px solid var(--line);border-radius:12px;padding:12px 14px}}
.stat b{{display:block;font-size:22px}}.stat span{{color:var(--mut);font-size:13px}}
.answer{{background:var(--card);border:1px solid var(--line);border-left:4px solid #5B8DEF;
border-radius:10px;padding:12px 14px;margin-bottom:16px}}
.card{{background:var(--card);border:1px solid var(--line);border-top:4px solid var(--c);
border-radius:12px;margin:14px 0;overflow:hidden}}
.card h2{{font-size:16px;margin:0;padding:12px 14px;display:flex;align-items:center;gap:8px}}
.dot{{width:10px;height:10px;border-radius:50%;background:var(--c)}}
.n{{margin-left:auto;color:var(--mut);font-weight:500;font-size:13px}}
.card table{{width:100%;border-collapse:collapse}}
td{{padding:10px 14px;border-top:1px solid var(--line);vertical-align:top}}
td small{{display:block;color:var(--mut);font-size:12.5px}}
.due{{white-space:nowrap;width:1%;font-weight:600}}.t{{font-weight:500}}
.st{{color:var(--mut);white-space:nowrap;width:1%}}
.badge{{white-space:nowrap;font-size:12px;font-weight:600;padding:3px 8px;border-radius:999px}}
.red{{background:#fde2e1;color:#b42318}}.amber{{background:#fef0c7;color:#935f00}}
.green{{background:#dcfae6;color:#067647}}.muted{{background:var(--line);color:var(--mut)}}
ul{{color:var(--mut);padding-left:18px}}footer{{color:var(--mut);font-size:12px;margin-top:24px}}
@media (max-width:560px){{.st{{display:none}}td{{padding:9px 10px}}}}
</style></head><body><main>
<header><h1>{e(title)}</h1><p>Generated {e(fmt_date(gen))} · live from Google Sheets via Claude + MCP</p></header>
<div class="stats">
<div class="stat"><b>{total}</b><span>items</span></div>
<div class="stat"><b>{urgent}</b><span>due in ≤ 3 days</span></div>
<div class="stat"><b>{e(fmt_date(nxt.get('due_date'))) if nxt else '—'}</b><span>next deadline</span></div>
</div>
{f"<div class='answer'>{e(answer)}</div>" if answer else ""}
{"".join(cards) if cards else "<p>No matching items.</p>"}
{("<ul>" + "".join(f"<li>{e(n)}</li>" for n in notes) + "</ul>") if notes else ""}
<footer>Always cross-check LMS / Google Classroom for the final schedule.</footer>
</main></body></html>"""
os.makedirs(a.outdir, exist_ok=True)
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
path = os.path.join(a.outdir, f"ts03-{stamp}.html")
with open(path, "w") as f:
f.write(doc)
with open(os.path.join(a.outdir, "latest.html"), "w") as f:
f.write(doc)
print(f"HTML saved: {path}")
if not a.no_open and not os.environ.get("SSH_CONNECTION"):
subprocess.run(["open", path])
elif os.environ.get("SSH_CONNECTION"):
print("(SSH session: not opening. On iPhone, open the URL from: ts03-link)")
Questions or corrections: [email protected]