samayly.com is live. Hosted plans open soon. Join the waitlist →
python210 linescopy and edit

scripts/ts03-render

Copy this file into your setup and edit the marked values. Do not use it as-is.

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]