// ============================================================ // Vital — Token Dashboard Widget for Scriptable (iPhone) // ============================================================ const WIDGET_URL = "https://clawstin.org/vital.json"; // Colors const C = { bg: new Color("#0d0d0d"), bgCard: new Color("#1a1a1a"), label: new Color("#888888"), white: new Color("#f0f0f0"), green: new Color("#34c759"), yellow: new Color("#ffd60a"), orange: new Color("#ff9f0a"), red: new Color("#ff3b30"), blue: new Color("#0a84ff"), purple: new Color("#bf5af2"), gaugeFill: new Color("#34c759"), gaugeBg: new Color("#2c2c2e"), }; // ───────────────────────────────────────────────────────────── // Data loading // ───────────────────────────────────────────────────────────── async function loadData() { try { const req = new Request(WIDGET_URL); req.timeoutInterval = 8; return await req.loadJSON(); } catch(e) { return null; } } // ───────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────── function gaugeColor(pct) { if (pct > 50) return C.green; if (pct > 20) return C.yellow; if (pct > 10) return C.orange; return C.red; } function dayTypeColor(dayType) { if (dayType === "heavy") return C.red; if (dayType === "light") return C.blue; if (dayType === "normal") return C.green; return C.label; } function dayTypeEmoji(dayType) { if (dayType === "heavy") return "🔥"; if (dayType === "light") return "🧊"; if (dayType === "normal") return "✅"; return "❓"; } function severityColor(severity) { if (severity === "CIRCUIT_BREAKER") return C.red; if (severity === "RED") return C.red; if (severity === "YELLOW") return C.yellow; return C.green; } function formatTokens(n) { if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M"; if (n >= 1_000) return (n / 1_000).toFixed(0) + "K"; return String(n); } function formatTime(isoStr) { try { const d = new Date(isoStr); const now = new Date(); const diffMin = Math.round((now - d) / 60000); if (diffMin < 60) return diffMin + "m ago"; return Math.round(diffMin / 60) + "h ago"; } catch (e) { return "?"; } } // ───────────────────────────────────────────────────────────── // Draw gauge bar (horizontal) // ───────────────────────────────────────────────────────────── function addGaugeBar(stack, pct, widthPx) { const outer = stack.addStack(); outer.size = new Size(widthPx, 14); outer.backgroundColor = C.gaugeBg; outer.cornerRadius = 7; outer.layoutHorizontally(); const fillWidth = Math.max(4, Math.round((pct / 100) * widthPx)); const fill = outer.addStack(); fill.size = new Size(fillWidth, 14); fill.backgroundColor = gaugeColor(pct); fill.cornerRadius = 7; } // ───────────────────────────────────────────────────────────── // Widget builders // ───────────────────────────────────────────────────────────── async function buildSmall(data) { const w = new ListWidget(); w.backgroundColor = C.bg; w.setPadding(12, 14, 10, 14); if (!data) { const t = w.addText("⚠️ No data"); t.textColor = C.red; t.font = Font.boldSystemFont(14); w.addSpacer(); const sub = w.addText("Check Vital config"); sub.textColor = C.label; sub.font = Font.systemFont(11); return w; } // Title const title = w.addText("⚡ VITAL"); title.textColor = C.white; title.font = Font.boldSystemFont(13); w.addSpacer(4); // Remaining balance — primary stat const balPct = data.balance_gauge_pct ?? data.gauge_pct ?? 0; const balLeft = data.remaining_balance; const balText = balLeft != null ? w.addText(`$${balLeft.toFixed(2)} left`) : w.addText(`${balPct.toFixed(0)}% left`); balText.textColor = gaugeColor(balPct); balText.font = Font.boldSystemFont(24); w.addSpacer(4); // Balance gauge bar addGaugeBar(w, balPct, 130); w.addSpacer(5); // Today's spend + burn rate const spendRow = w.addStack(); spendRow.layoutHorizontally(); const spentTxt = spendRow.addText(`$${(data.today_total_cost ?? 0).toFixed(2)} today`); spentTxt.textColor = C.label; spentTxt.font = Font.systemFont(10); spendRow.addSpacer(); const rateTxt = spendRow.addText(`$${(data.cost_per_hour ?? 0).toFixed(2)}/hr`); rateTxt.textColor = C.label; rateTxt.font = Font.systemFont(10); w.addSpacer(4); // Day type const dayRow = w.addStack(); dayRow.layoutHorizontally(); const dtEmoji = dayRow.addText(dayTypeEmoji(data.day_type)); dtEmoji.font = Font.systemFont(12); dayRow.addSpacer(4); const dtLabel = dayRow.addText((data.day_type ?? "unknown").toUpperCase()); dtLabel.textColor = dayTypeColor(data.day_type); dtLabel.font = Font.boldSystemFont(11); w.addSpacer(); // Last updated const updated = w.addText("↻ " + formatTime(data.last_updated)); updated.textColor = C.label; updated.font = Font.systemFont(9); return w; } async function buildMedium(data) { const w = new ListWidget(); w.backgroundColor = C.bg; w.setPadding(12, 16, 10, 16); if (!data) { const t = w.addText("⚠️ Vital — No data available"); t.textColor = C.red; t.font = Font.boldSystemFont(14); w.addSpacer(); const sub = w.addText("Check WIDGET_URL in the script config."); sub.textColor = C.label; sub.font = Font.systemFont(11); return w; } // ── Row 1: Title + severity dot ────────────────────────── const row1 = w.addStack(); row1.layoutHorizontally(); row1.centerAlignContent(); const titleTxt = row1.addText("⚡ VITAL"); titleTxt.textColor = C.white; titleTxt.font = Font.boldSystemFont(15); row1.addSpacer(); // Severity badge const sev = data.severity ?? null; const sevDot = row1.addText(sev ? `● ${sev}` : "● OK"); sevDot.textColor = severityColor(sev); sevDot.font = Font.boldSystemFont(11); w.addSpacer(6); // ── Row 2: Balance headline + gauge ────────────────────── const balPct = data.balance_gauge_pct ?? data.gauge_pct ?? 0; const balLeft = data.remaining_balance; // Large remaining balance const balHeadline = w.addStack(); balHeadline.layoutHorizontally(); balHeadline.centerAlignContent(); const balBig = balHeadline.addText( balLeft != null ? `$${balLeft.toFixed(2)} left` : `${balPct.toFixed(0)}% left` ); balBig.textColor = gaugeColor(balPct); balBig.font = Font.boldSystemFont(26); balHeadline.addSpacer(); // Today spend + rate stacked to the right const balMeta = balHeadline.addStack(); balMeta.layoutVertically(); balMeta; // right-aligned via spacer const todayTxt = balMeta.addText(`$${(data.today_total_cost ?? 0).toFixed(3)} today`); todayTxt.textColor = C.label; todayTxt.font = Font.systemFont(10); const rateTxt2 = balMeta.addText(`$${(data.cost_per_hour ?? 0).toFixed(3)}/hr`); rateTxt2.textColor = C.white; rateTxt2.font = Font.boldSystemFont(12); w.addSpacer(5); addGaugeBar(w, balPct, 290); w.addSpacer(3); const gaugeLabel = w.addStack(); gaugeLabel.layoutHorizontally(); const anchorDate = data.anchor_date ? `since ${data.anchor_date}` : "balance"; const gl = gaugeLabel.addText(`$${(data.spend_since_anchor ?? data.today_total_cost ?? 0).toFixed(3)} spent ${anchorDate}`); gl.textColor = C.label; gl.font = Font.systemFont(11); gaugeLabel.addSpacer(); const gr = gaugeLabel.addText(`${balPct.toFixed(0)}% remaining`); gr.textColor = gaugeColor(balPct); gr.font = Font.boldSystemFont(11); w.addSpacer(8); // ── Row 3: Stats ───────────────────────────────────────── const statsRow = w.addStack(); statsRow.layoutHorizontally(); statsRow.spacing = 12; // Tokens const tokBlock = statsRow.addStack(); tokBlock.layoutVertically(); const tokVal = tokBlock.addText(formatTokens(data.today_tokens ?? 0)); tokVal.textColor = C.white; tokVal.font = Font.boldSystemFont(14); const tokLbl = tokBlock.addText("tok today"); tokLbl.textColor = C.label; tokLbl.font = Font.systemFont(10); statsRow.addSpacer(); // Burn rate (tokens/hr) const rateBlock = statsRow.addStack(); rateBlock.layoutVertically(); rateBlock.centerAlignContent(); const rateKTok = Math.round((data.tokens_per_hour ?? 0) / 1000); const rateVal = rateBlock.addText(`${rateKTok}K tok/hr`); rateVal.textColor = C.white; rateVal.font = Font.boldSystemFont(14); const rateLbl = rateBlock.addText("burn rate"); rateLbl.textColor = C.label; rateLbl.font = Font.systemFont(10); statsRow.addSpacer(); // Day type const dayBlock = statsRow.addStack(); dayBlock.layoutVertically(); dayBlock; // right-aligned via spacer const dayVal = dayBlock.addText(dayTypeEmoji(data.day_type) + " " + (data.day_type ?? "?").toUpperCase()); dayVal.textColor = dayTypeColor(data.day_type); dayVal.font = Font.boldSystemFont(14); const dayRatioStr = data.day_type_ratio != null ? `${data.day_type_ratio}x baseline` : "no baseline"; const dayLbl = dayBlock.addText(dayRatioStr); dayLbl.textColor = C.label; dayLbl.font = Font.systemFont(10); w.addSpacer(); // ── Footer ─────────────────────────────────────────────── const footer = w.addStack(); footer.layoutHorizontally(); const modeTxt = footer.addText(`Mode: ${(data.mode ?? "?").toUpperCase()} · Day ${data.days_observed ?? "?"} observe`); modeTxt.textColor = C.label; modeTxt.font = Font.systemFont(9); footer.addSpacer(); const updTxt = footer.addText("↻ " + formatTime(data.last_updated)); updTxt.textColor = C.label; updTxt.font = Font.systemFont(9); return w; } // ───────────────────────────────────────────────────────────── // Entry point // ───────────────────────────────────────────────────────────── const data = await loadData(); const size = config.widgetFamily; let widget; if (size === "small") { widget = await buildSmall(data); } else { // medium or large — use medium layout widget = await buildMedium(data); } if (config.runsInWidget) { Script.setWidget(widget); } else { // Preview in app await widget.presentMedium(); } Script.complete();