{
  "$schema": "../../oss/scenecast/schemas/scene-blueprint-v1.json",
  "version": 1,
  "id": "scn_kol_tracker",
  "slug": "kol-tracker",
  "name": "KOL Tracker",
  "icon": "person.3.sequence",
  "tint": "#EC4899",
  "settings": {
    "description": "Track any roster of creators across Instagram, TikTok, Facebook and X: the influencers a brand works with, the accounts an agency manages, the voices a newsroom follows. Every hour the agent pulls their new posts, once a day it snapshots follower counts, and a dashboard shows who is growing, who posted, and which posts mention you. The roster is a table in the scene: add a row and the next poll picks it up.",
    "cost": "Metered on your plan, per run. A roster of 200 creators on three platforms, polled hourly with a daily follower snapshot, is about $155 a month. A quiet hour returns nothing and costs nothing."
  },
  "inputs": {
    "brand_handles": {
      "type": "string",
      "required": false,
      "description": "The accounts you want mentions of, comma-separated: your brand, your products, a campaign hashtag. Posts by roster creators that mention any of them are flagged on the dashboard."
    }
  },
  "assets": [
    {
      "id": "intro",
      "type": "daslab/note",
      "name": "About this tracker",
      "fields": {
        "content": "# KOL Tracker\n\nEvery hour the agent reads the **Roster** below, pulls every new post from each account on Instagram, TikTok, Facebook and X, and updates the **Dashboard**. Once a day it snapshots follower counts, so the dashboard shows who is growing, who posted, and which posts mention your own accounts.\n\n## How it works\n\n- **Roster** is a table you edit: one row per creator, one column per platform. Leave a cell empty if they are not on that platform.\n- **Hourly poll** asks each platform only for posts newer than two hours. A quiet hour returns nothing and costs nothing.\n- **Daily snapshot** records follower counts per platform, so the dashboard can show seven-day change.\n- **Dashboard** is a view: the leaderboard, the last 48 hours of posts by engagement, and posts that mention `${inputs.brand_handles}`, whether that is your brand, a product, or a campaign hashtag.\n- **Run log** gets one line per run with what was found and what it cost.\n\n## What it costs\n\nRuns are metered on this scene's plan. A roster of 200 creators across three platforms, polled hourly with a daily snapshot, is about $155 a month.\n\n## Try\n\n- *\"Poll now.\"*\n- *\"Add Nike: instagram nike, tiktok nike, x nike.\"*\n- *\"Who grew the most this week?\"*\n- *\"Show me every post that mentioned us in the last 14 days.\"*\n- *\"Which creators went quiet this month?\"*\n"
      },
      "layout": {
        "sortOrder": 0,
        "size": "medium"
      }
    },
    {
      "id": "roster",
      "type": "daslab/note",
      "name": "Roster",
      "fields": {
        "content": "| Name | Instagram | TikTok | Facebook | X |\n|---|---|---|---|---|\n| Nike | nike | nike | https://www.facebook.com/nike | nike |\n| Bombas | bombas | bombas | | |\n| NY Fashion Tech Lab | nyftlab | | | |\n"
      },
      "layout": {
        "sortOrder": 1,
        "size": "large"
      }
    },
    {
      "id": "feed",
      "type": "apify/feed",
      "name": "Social data feed",
      "fields": {
        "platforms": "instagram, tiktok, facebook, x",
        "note": "Enables the platform tools in this scene. Runs are metered on the scene's plan."
      },
      "layout": {
        "sortOrder": 2,
        "size": "icon"
      }
    },
    {
      "id": "dashboard",
      "type": "daslab/webview",
      "name": "Dashboard",
      "fields": {
        "html": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<title>KOL Tracker</title>\n<style>\n/* ---------------------------------------------------------------------------\n   Daslab tokens. Light is `paper`, dark is `bench` — the same palette lifted\n   for a dark ground (server/src/lib/html.ts), so this view reads as one\n   language whether it sits in the app, in a scene, or embedded on the site.\n\n   Colour does exactly two jobs here, and both were computed rather than\n   chosen. Daslab's four brand accents fail as a categorical set in both modes\n   (validate_palette: normal-vision ΔE 12.7 light, lightness band + chroma\n   dark), so platform identity is carried by a text pill instead of a hue, and\n   the palette collapses to one accent for every data mark plus a direction\n   pair. Every colour below clears 3:1 on its own surface; the -30 ink steps\n   do not, so they are hairlines and never text.\n--------------------------------------------------------------------------- */\n:root {\n  color-scheme: light;\n  --surface:      #F2EEE5;\n  --surface-2:    #EAE5DA;\n  --surface-3:    #E3DDD0;\n  --line:         rgba(27,27,29,.14);\n  --line-strong:  rgba(27,27,29,.24);\n  --ink:          #1B1B1D;\n  --ink-2:        #6E6E70;\n  --accent:       #315FC5;\n  --accent-wash:  rgba(49,95,197,.14);\n  --up:           #1F7A45;\n  --down:         #A84638;\n  --radius:       12px;\n  --radius-sm:    9px;\n  --radius-xs:    6px;\n  --font:         'Inter', system-ui, -apple-system, \"Segoe UI\", sans-serif;\n  --mono:         'JetBrains Mono', ui-monospace, Menlo, monospace;\n}\n@media (prefers-color-scheme: dark) {\n  :root:where(:not([data-theme=\"light\"])) {\n    color-scheme: dark;\n    --surface:     #17150f;\n    --surface-2:   #1d1b14;\n    --surface-3:   #242118;\n    --line:        #2b2820;\n    --line-strong: #3a3427;\n    --ink:         #ece8df;\n    --ink-2:       #8a8478;\n    --accent:      #6b9bff;\n    --accent-wash: rgba(107,155,255,.18);\n    --up:          #5fd68a;\n    --down:        #e0584d;\n  }\n}\n:root[data-theme=\"dark\"] {\n  color-scheme: dark;\n  --surface:     #17150f;\n  --surface-2:   #1d1b14;\n  --surface-3:   #242118;\n  --line:        #2b2820;\n  --line-strong: #3a3427;\n  --ink:         #ece8df;\n  --ink-2:       #8a8478;\n  --accent:      #6b9bff;\n  --accent-wash: rgba(107,155,255,.18);\n  --up:          #5fd68a;\n  --down:        #e0584d;\n}\n\n* { box-sizing: border-box; }\n/* `display` on a component beats the bare `hidden` attribute — without this the\n   stale chip renders as an empty outline whenever the data is healthy. */\n[hidden] { display: none !important; }\nhtml, body { background: var(--surface); }\nbody {\n  margin: 0; padding: 22px 20px 30px;\n  color: var(--ink); font-family: var(--font);\n  font-size: 14px; line-height: 1.5;\n  -webkit-font-smoothing: antialiased;\n}\n\n/* ---- header ---- */\n.head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px 14px; margin-bottom: 22px; }\n.kicker {\n  font-family: var(--mono); font-size: 10.5px; letter-spacing: .14em;\n  text-transform: uppercase; color: var(--ink-2);\n}\n.chip {\n  display: inline-flex; align-items: center; gap: 6px;\n  font-family: var(--mono); font-size: 10.5px; letter-spacing: .06em;\n  color: var(--ink-2); padding: 3px 9px; border: 1px solid var(--line);\n  border-radius: 999px; white-space: nowrap;\n}\n.chip .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--up); }\n.chip.warn { color: var(--down); border-color: var(--down); }\n.chip.warn .dot { background: var(--down); }\n\n/* ---- hero + tiles ---- */\n.top { display: grid; grid-template-columns: minmax(240px, 1.15fr) 2fr; gap: 14px; margin-bottom: 26px; }\n.panel { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 18px 20px; }\n.label { font-size: 11.5px; letter-spacing: .02em; color: var(--ink-2); margin-bottom: 8px; }\n.hero { font-size: 52px; line-height: 1; font-weight: 600; letter-spacing: -.03em; }\n.hero-sub { margin-top: 10px; display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--ink-2); }\n.tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; }\n.tile { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius); padding: 16px 18px; display: flex; flex-direction: column; justify-content: space-between; }\n.tile .value { font-size: 27px; font-weight: 600; letter-spacing: -.02em; line-height: 1.1; }\n.tile .foot { font-size: 11.5px; color: var(--ink-2); margin-top: 6px; }\n\n/* Direction never rides on hue alone: an arrow glyph and a signed number\n   carry it too, per the status rule in the dataviz method. */\n.delta { display: inline-flex; align-items: center; gap: 4px; font-size: 12.5px; font-weight: 550; font-variant-numeric: tabular-nums; }\n.delta.up { color: var(--up); }\n.delta.down { color: var(--down); }\n.delta.flat { color: var(--ink-2); }\n\n/* ---- sections ---- */\nsection { margin-top: 30px; }\n.sec-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }\nh2 { font-size: 12px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-2); margin: 0; }\n.count { font-family: var(--mono); font-size: 11px; color: var(--ink-2); }\n\n/* ---- roster ---- */\n.tablewrap { border: 1px solid var(--line); border-radius: var(--radius); overflow-x: auto; background: var(--surface-2); }\ntable { border-collapse: collapse; width: 100%; min-width: 620px; }\nth, td { padding: 11px 14px; text-align: left; border-bottom: 1px solid var(--line); vertical-align: middle; }\nthead th {\n  font-family: var(--mono); font-size: 10px; font-weight: 500; letter-spacing: .1em;\n  text-transform: uppercase; color: var(--ink-2); background: var(--surface-3);\n  border-bottom-color: var(--line-strong); white-space: nowrap; position: sticky; top: 0;\n}\ntbody tr:last-child td { border-bottom: none; }\ntbody tr:hover { background: var(--surface-3); }\n.num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }\n.who { font-weight: 550; }\n.pills { display: flex; flex-wrap: nowrap; gap: 4px; }\n.pill {\n  font-family: var(--mono); font-size: 9.5px; letter-spacing: .06em; text-transform: uppercase;\n  color: var(--ink-2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; white-space: nowrap;\n}\n.spark { display: block; }\n.spark-empty { font-size: 11px; color: var(--ink-2); }\n\n/* ---- posts ---- */\n.feed { display: grid; gap: 10px; grid-template-columns: repeat(auto-fill, minmax(310px, 1fr)); }\n.post { background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius-sm); padding: 13px 15px; display: flex; flex-direction: column; gap: 8px; }\n.post.flag { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }\n.post-top { display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 9px; font-size: 11.5px; color: var(--ink-2); }\n.post-top .who { color: var(--ink); font-size: 13px; }\n.post-text { font-size: 13px; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }\n.post-metrics { display: flex; flex-wrap: wrap; gap: 12px; font-size: 11.5px; color: var(--ink-2); font-variant-numeric: tabular-nums; }\n.post-metrics b { font-weight: 550; color: var(--ink); }\na { color: var(--accent); text-decoration: none; }\na:hover { text-decoration: underline; }\n\n/* ---- empty ---- */\n.empty { border: 1px dashed var(--line-strong); border-radius: var(--radius); padding: 42px 24px; text-align: center; }\n.empty h3 { margin: 0 0 6px; font-size: 15px; font-weight: 600; }\n.empty p { margin: 0 auto; max-width: 380px; font-size: 13px; color: var(--ink-2); line-height: 1.6; }\n.empty code { font-family: var(--mono); font-size: 12px; background: var(--surface-3); border-radius: var(--radius-xs); padding: 1px 5px; }\n.quiet { font-size: 13px; color: var(--ink-2); padding: 14px 2px; }\n\n/* ---- tooltip ---- */\n#tip {\n  position: fixed; z-index: 20; pointer-events: none; opacity: 0; transition: opacity .1s;\n  background: var(--ink); color: var(--surface); font-size: 11.5px; line-height: 1.45;\n  padding: 6px 9px; border-radius: var(--radius-xs); white-space: nowrap;\n  font-variant-numeric: tabular-nums; box-shadow: 0 4px 14px rgba(0,0,0,.22);\n}\n#tip b { font-weight: 600; }\n\n@media (max-width: 720px) {\n  body { padding: 18px 14px 26px; }\n  .top { grid-template-columns: 1fr; }\n  .tiles { grid-template-columns: repeat(3, 1fr); }\n  .tile { padding: 13px 14px; }\n  .tile .value { font-size: 21px; }\n  .hero { font-size: 40px; }\n}\n</style>\n</head>\n<body>\n  <div class=\"head\">\n    <span class=\"kicker\">KOL Tracker</span>\n    <span id=\"freshness\" class=\"chip\"><span class=\"dot\"></span><span id=\"freshness-text\"></span></span>\n    <span id=\"state\" class=\"chip warn\" hidden></span>\n  </div>\n  <div id=\"root\"></div>\n  <div id=\"tip\"></div>\n\n<script>\n(() => {\n  \"use strict\";\n\n  const DAY = 86400000;\n  const NOW = Date.now();\n  const PLATFORMS = [\"instagram\", \"tiktok\", \"facebook\", \"x\"];\n\n  const data = window.__DASLAB_WIDGET_DATA__ || {};\n  const meta = window.__DASLAB_WIDGET_META__ || {};\n  const kols = Array.isArray(data.kols) ? data.kols : [];\n  const posts = (Array.isArray(data.posts) ? data.posts : []).filter(p => p && p.timestamp);\n\n  renderFreshness();\n  document.getElementById(\"root\").innerHTML =\n    kols.length || posts.length ? dashboard() : emptyState();\n  wireSparklineTooltips();\n\n  // ---- the view -----------------------------------------------------------\n\n  function dashboard() {\n    const rows = leaderboard();\n    return summary(rows) + roster(rows) + mentions() + latest();\n  }\n\n  /** One hero figure, then the counts that qualify it. */\n  function summary(rows) {\n    const reach = sum(rows.map(r => r.reach));\n    const delta = rows.some(r => r.delta !== null) ? sum(rows.map(r => r.delta || 0)) : null;\n    const posts7 = postsWithin(7 * DAY).length;\n    const flagged = posts.filter(p => p.mentions_brand && within(p.timestamp, 14 * DAY)).length;\n    return `\n      <div class=\"top\">\n        <div class=\"panel\">\n          <div class=\"label\">Total reach across the roster</div>\n          <div class=\"hero\">${compact(reach)}</div>\n          <div class=\"hero-sub\">${deltaTag(delta, \"vs 7 days ago\")}</div>\n        </div>\n        <div class=\"tiles\">\n          ${tile(\"Posts\", posts7, \"last 7 days\")}\n          ${tile(\"Mentions\", flagged, \"last 14 days\")}\n          ${tile(\"Creators\", rows.length, plural(new Set(rows.flatMap(r => r.platforms)).size, \"platform\", \"platforms\"))}\n        </div>\n      </div>`;\n  }\n\n  function tile(label, value, foot) {\n    return `<div class=\"tile\"><div><div class=\"label\">${esc(label)}</div>\n      <div class=\"value\">${compact(value)}</div></div>\n      <div class=\"foot\">${esc(foot)}</div></div>`;\n  }\n\n  /** One row per creator: who, where, how big, which way, how loud. */\n  function leaderboard() {\n    return kols.map(k => {\n      const followers = k.followers || {};\n      const reach = sum(PLATFORMS.map(p => followers[p]));\n      const series = history(k);\n      const past = series.length > 1 ? series[0].value : null;\n      const mine = posts.filter(p => p.kol === k.name && within(p.timestamp, 7 * DAY));\n      const eng = mine.length ? Math.round(sum(mine.map(engagement)) / mine.length) : null;\n      return {\n        name: k.name || \"—\",\n        platforms: PLATFORMS.filter(p => (k.handles || {})[p]),\n        reach, series,\n        delta: past === null ? null : reach - past,\n        posts7: mine.length,\n        eng,\n        rate: eng !== null && reach > 0 ? eng / reach : null,\n      };\n    }).sort((a, b) => b.reach - a.reach);\n  }\n\n  function roster(rows) {\n    if (!rows.length) return \"\";\n    const body = rows.map(r => `\n      <tr>\n        <td class=\"who\">${esc(r.name)}</td>\n        <td><div class=\"pills\">${r.platforms.map(p => `<span class=\"pill\">${esc(p)}</span>`).join(\"\")}</div></td>\n        <td class=\"num\">${r.reach ? compact(r.reach) : \"—\"}</td>\n        <td class=\"num\">${deltaTag(r.delta, \"\")}</td>\n        <td>${sparkline(r.series, r.name)}</td>\n        <td class=\"num\">${r.posts7 || \"—\"}</td>\n        <td class=\"num\">${r.eng === null ? \"—\" : compact(r.eng)}</td>\n        <td class=\"num\">${rate(r.rate)}</td>\n      </tr>`).join(\"\");\n    return `\n      <section>\n        <div class=\"sec-head\"><h2>Roster</h2><span class=\"count\">${plural(rows.length, \"creator\", \"creators\")}</span></div>\n        <div class=\"tablewrap\"><table>\n          <thead><tr>\n            <th>Creator</th><th>On</th><th class=\"num\">Reach</th><th class=\"num\">7d</th>\n            <th>Trend</th><th class=\"num\">Posts 7d</th><th class=\"num\">Avg eng.</th><th class=\"num\">Rate</th>\n          </tr></thead>\n          <tbody>${body}</tbody>\n        </table></div>\n      </section>`;\n  }\n\n  function mentions() {\n    const list = posts.filter(p => p.mentions_brand && within(p.timestamp, 14 * DAY)).sort(byEngagement);\n    if (!list.length) return \"\";\n    return section(\"Mentioned you\", plural(list.length, \"post\", \"posts\") + \" · 14 days\", list.slice(0, 12), true);\n  }\n\n  function latest() {\n    const list = postsWithin(2 * DAY).sort(byEngagement);\n    if (!list.length) {\n      return `<section><div class=\"sec-head\"><h2>Latest</h2></div>\n        <div class=\"quiet\">Nothing posted in the last 48 hours. The next hourly poll will pick up anything new.</div></section>`;\n    }\n    return section(\"Latest\", \"48 hours · by engagement\", list.slice(0, 24), false);\n  }\n\n  function section(title, count, list, flag) {\n    return `\n      <section>\n        <div class=\"sec-head\"><h2>${esc(title)}</h2><span class=\"count\">${esc(count)}</span></div>\n        <div class=\"feed\">${list.map(p => card(p, flag)).join(\"\")}</div>\n      </section>`;\n  }\n\n  function card(p, flag) {\n    const metrics = [[\"likes\", p.likes], [\"comments\", p.comments], [\"shares\", p.shares], [\"views\", p.views]]\n      .filter(([, v]) => typeof v === \"number\")\n      .map(([k, v]) => `<span><b>${compact(v)}</b> ${k}</span>`).join(\"\");\n    return `\n      <article class=\"post${flag || p.mentions_brand ? \" flag\" : \"\"}\">\n        <div class=\"post-top\">\n          <span class=\"who\">${esc(p.kol || p.author || \"Unknown\")}</span>\n          <span class=\"pill\">${esc(p.platform || \"\")}</span>\n          <span>${ago(p.timestamp)}</span>\n          ${p.url ? `<a href=\"${esc(p.url)}\" target=\"_blank\" rel=\"noopener noreferrer\">open</a>` : \"\"}\n        </div>\n        ${p.text ? `<div class=\"post-text\">${esc(p.text)}</div>` : \"\"}\n        ${metrics ? `<div class=\"post-metrics\">${metrics}</div>` : \"\"}\n      </article>`;\n  }\n\n  function emptyState() {\n    return `<div class=\"empty\">\n      <h3>Nothing tracked yet</h3>\n      <p>Add creators to the <b>Roster</b> note, then run <code>poll-now</code> — or wait for the next hourly poll.\n         Follower counts arrive with the nightly snapshot.</p>\n    </div>`;\n  }\n\n  // ---- sparkline ----------------------------------------------------------\n\n  /** Follower totals per snapshot date, oldest first. */\n  function history(k) {\n    return (Array.isArray(k.history) ? k.history : [])\n      .filter(h => h && h.date)\n      .map(h => ({ date: h.date, value: sum(PLATFORMS.map(p => h[p])) }))\n      .sort((a, b) => a.date < b.date ? -1 : 1)\n      .slice(-30);\n  }\n\n  /**\n   * One series, one hue, 2px, no per-point markers — the row already carries\n   * the number, so the line only has to show the shape.\n   */\n  function sparkline(series, name) {\n    if (series.length < 2) return `<span class=\"spark-empty\">—</span>`;\n    const W = 84, H = 22, PAD = 2;\n    const values = series.map(s => s.value);\n    const lo = Math.min(...values), hi = Math.max(...values);\n    const span = hi - lo || 1;\n    const x = i => PAD + (i * (W - PAD * 2)) / (series.length - 1);\n    const y = v => H - PAD - ((v - lo) / span) * (H - PAD * 2);\n    const d = series.map((s, i) => `${i ? \"L\" : \"M\"}${x(i).toFixed(1)},${y(s.value).toFixed(1)}`).join(\"\");\n    const points = series.map((s, i) => `${x(i).toFixed(1)}:${y(s.value).toFixed(1)}:${s.date}:${s.value}`).join(\"|\");\n    return `<svg class=\"spark\" width=\"${W}\" height=\"${H}\" viewBox=\"0 0 ${W} ${H}\" role=\"img\"\n      aria-label=\"${esc(name)} follower trend, ${series.length} snapshots, ${compact(lo)} to ${compact(hi)}\"\n      data-points=\"${esc(points)}\">\n      <path d=\"${d}\" fill=\"none\" stroke=\"var(--accent)\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"/>\n    </svg>`;\n  }\n\n  /** An SVG chart is interactive by default: nearest-point crosshair on hover. */\n  function wireSparklineTooltips() {\n    const tip = document.getElementById(\"tip\");\n    for (const svg of document.querySelectorAll(\".spark\")) {\n      const points = svg.dataset.points.split(\"|\").map(p => {\n        const [px, py, date, value] = p.split(\":\");\n        return { x: +px, y: +py, date, value: +value };\n      });\n      const marker = document.createElementNS(\"http://www.w3.org/2000/svg\", \"circle\");\n      marker.setAttribute(\"r\", \"3\");\n      marker.setAttribute(\"fill\", \"var(--accent)\");\n      marker.setAttribute(\"stroke\", \"var(--surface-2)\");\n      marker.setAttribute(\"stroke-width\", \"2\");\n      marker.style.display = \"none\";\n      svg.appendChild(marker);\n\n      svg.addEventListener(\"pointermove\", e => {\n        const box = svg.getBoundingClientRect();\n        const local = ((e.clientX - box.left) / box.width) * svg.viewBox.baseVal.width;\n        const near = points.reduce((a, b) => Math.abs(b.x - local) < Math.abs(a.x - local) ? b : a);\n        marker.setAttribute(\"cx\", near.x);\n        marker.setAttribute(\"cy\", near.y);\n        marker.style.display = \"\";\n        tip.innerHTML = `<b>${compact(near.value)}</b> followers<br>${esc(near.date)}`;\n        tip.style.opacity = \"1\";\n        tip.style.left = Math.min(e.clientX + 12, innerWidth - 150) + \"px\";\n        tip.style.top = Math.max(e.clientY - 44, 6) + \"px\";\n      });\n      svg.addEventListener(\"pointerleave\", () => {\n        marker.style.display = \"none\";\n        tip.style.opacity = \"0\";\n      });\n    }\n  }\n\n  // ---- freshness ----------------------------------------------------------\n\n  function renderFreshness() {\n    const at = data.updated_at || meta.computed_at;\n    document.getElementById(\"freshness-text\").textContent = at ? \"polled \" + ago(at) : \"never polled\";\n    if (!at) document.querySelector(\"#freshness .dot\").style.background = \"var(--ink-2)\";\n    const stale = meta.state === \"failed\" || meta.stale;\n    if (!stale) return;\n    const el = document.getElementById(\"state\");\n    el.textContent = meta.state === \"failed\" ? \"last refresh failed\" : \"stale\";\n    el.hidden = false;\n  }\n\n  // ---- helpers ------------------------------------------------------------\n\n  function deltaTag(v, suffix) {\n    if (v === null || v === undefined) return `<span class=\"delta flat\">—${suffix ? \" \" + esc(suffix) : \"\"}</span>`;\n    const dir = v > 0 ? \"up\" : v < 0 ? \"down\" : \"flat\";\n    const arrow = v > 0 ? \"▲\" : v < 0 ? \"▼\" : \"•\";\n    const sign = v > 0 ? \"+\" : \"\";\n    return `<span class=\"delta ${dir}\">${arrow} ${sign}${compact(v)}${suffix ? ` <span style=\"color:var(--ink-2);font-weight:400\">${esc(suffix)}</span>` : \"\"}</span>`;\n  }\n\n  /** A rate that rounds to zero is not zero — say so rather than printing 0.00%. */\n  function rate(v) {\n    if (v === null) return \"—\";\n    const pct = v * 100;\n    if (pct > 0 && pct < 0.01) return \"&lt;0.01%\";\n    return pct.toFixed(2) + \"%\";\n  }\n\n  function postsWithin(ms) { return posts.filter(p => within(p.timestamp, ms)); }\n  function within(ts, ms) { const t = Date.parse(ts); return !!t && NOW - t <= ms && NOW - t >= -DAY; }\n  function engagement(p) { return (p.likes || 0) + (p.comments || 0) + (p.shares || 0); }\n  function byEngagement(a, b) { return engagement(b) - engagement(a); }\n  function sum(xs) { return xs.reduce((a, b) => a + (typeof b === \"number\" && isFinite(b) ? b : 0), 0); }\n  function plural(n, one, many) { return `${n} ${n === 1 ? one : many}`; }\n\n  /** 1,284 · 12.9K · 4.2M — signed values keep their sign from the caller. */\n  function compact(n) {\n    if (typeof n !== \"number\" || !isFinite(n)) return \"—\";\n    const a = Math.abs(n);\n    if (a >= 1e6) return (a / 1e6).toFixed(a >= 1e7 ? 0 : 1) + \"M\";\n    if (a >= 1e4) return (a / 1e3).toFixed(a >= 1e5 ? 0 : 1) + \"K\";\n    return a.toLocaleString(\"en-US\");\n  }\n\n  function ago(ts) {\n    const t = Date.parse(ts);\n    if (!t) return \"\";\n    const mins = Math.round((NOW - t) / 60000);\n    if (mins < 1) return \"just now\";\n    if (mins < 60) return mins + \"m ago\";\n    const hrs = Math.round(mins / 60);\n    if (hrs < 48) return hrs + \"h ago\";\n    return Math.round(hrs / 24) + \"d ago\";\n  }\n\n  function esc(s) {\n    return String(s == null ? \"\" : s).replace(/[&<>\"']/g, c =>\n      ({ \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" })[c]);\n  }\n})();\n</script>\n</body>\n</html>\n",
        "data": {
          "type": "kol_tracker",
          "updated_at": null,
          "snapshot_at": null,
          "brand_handles": [],
          "kols": [],
          "posts": []
        }
      },
      "layout": {
        "sortOrder": 3,
        "size": "xlarge"
      }
    },
    {
      "id": "log",
      "type": "daslab/note",
      "name": "Run log",
      "fields": {
        "content": "_No runs yet. Use the **poll-now** task or wait for the next hourly run._\n"
      },
      "layout": {
        "sortOrder": 4,
        "size": "medium"
      }
    }
  ],
  "schedules": [
    {
      "id": "hourly-posts",
      "title": "New posts from the roster · hourly",
      "cron": "15 * * * *",
      "prompt": "Poll the roster for new posts.\n\n1. Read the **Roster** note. Each row is one creator: Name, Instagram username, TikTok username, Facebook page URL, X handle. An empty cell means the creator is not on that platform.\n2. Fetch what is new, in as few calls as possible, one call per platform with every handle in it. Skip a platform entirely when no row has a handle for it.\n   - `apify_instagram_posts` with every Instagram username as `profiles`, `newer_than` \"2 hours\", `limit` 10.\n   - `apify_tiktok_profiles` with every TikTok username, `videos_per_profile` 3, `newer_than` set to yesterday's date as YYYY-MM-DD.\n   - `apify_facebook_posts` with every Facebook URL as `page_urls`, `newer_than` \"2 hours\", `limit` 10.\n   - `apify_scrape_twitter` with every X handle as `handles`, `maxItems` 20; keep only tweets from the last two hours.\n3. Read the **Dashboard** cell with `daslab_read_cell` (`full: true`). Its data is the state: `{ type, updated_at, snapshot_at, brand_handles, kols[], posts[] }`. A kol is `{ name, handles: { instagram, tiktok, facebook, x }, followers: { instagram, tiktok, facebook, x }, history: [{ date, instagram, tiktok, facebook, x }], last_post_at }`. A post is `{ id, platform, kol, author, url, timestamp, text, likes, comments, shares, views, mentions_brand }`.\n4. Merge. For every fetched post build the post shape above with `kol` set to the roster Name it belongs to. `mentions_brand` is true when the text or mentions contain any of: ${inputs.brand_handles}. Add posts whose id is not present yet; for ids already present, update likes, comments, shares and views. Drop posts older than 60 days. Set each kol's `last_post_at`. Make sure every roster row exists in `kols` with its name and handles; never drop history.\n5. Write the merged state back with `daslab_write_data` on the Dashboard, keeping `type` \"kol_tracker\" and setting `updated_at` to now (ISO).\n6. Append one line to the **Run log** note, newest first: `poll <UTC time> · <n> new · <m> updated · $<sum of run.charged_usd across the calls>`.\n\nDo not summarise posts in prose. The dashboard renders them.",
      "assetIds": [
        "roster",
        "dashboard",
        "log"
      ],
      "enabled": true,
      "checks": [
        {
          "op": "contains",
          "path": "log.content",
          "substring": "poll",
          "ci": true,
          "why": "Every hourly run leaves a log line, so a silent run is visible."
        },
        {
          "op": "lte",
          "path": "$run.cost_usd",
          "value": 2.5,
          "expose": true,
          "severity": "fail",
          "why": "Hourly poll budget cap, model and data together."
        }
      ]
    },
    {
      "id": "daily-snapshot",
      "title": "Follower snapshot · 01:30 UTC",
      "cron": "30 1 * * *",
      "prompt": "Snapshot follower counts for the roster.\n\n1. Read the **Roster** note (Name, Instagram, TikTok, Facebook, X per row).\n2. One call per platform with every handle in it: `apify_instagram_profiles` with every Instagram username; `apify_tiktok_profiles` with every TikTok username and `videos_per_profile` 1 (the profile comes with the video). Facebook and X have no follower tool here, so leave those counts as they were.\n3. Read the **Dashboard** cell with `daslab_read_cell` (`full: true`). For each kol set `followers.instagram` and `followers.tiktok` from the results, and append `{ date: today (YYYY-MM-DD), instagram, tiktok, facebook, x }` to its `history`, replacing today's entry if one exists and keeping at most 120 entries. Make sure every roster row exists in `kols`.\n4. Set `snapshot_at` to now and write the state back with `daslab_write_data` on the Dashboard, keeping `type` \"kol_tracker\".\n5. Append one line to the **Run log** note, newest first: `snapshot <date> · <n> creators · $<sum of run.charged_usd>`.",
      "assetIds": [
        "roster",
        "dashboard",
        "log"
      ],
      "enabled": true,
      "checks": [
        {
          "op": "contains",
          "path": "log.content",
          "substring": "snapshot",
          "ci": true,
          "why": "Every daily run leaves a log line."
        },
        {
          "op": "lte",
          "path": "$run.cost_usd",
          "value": 5,
          "expose": true,
          "severity": "fail",
          "why": "Daily snapshot budget cap."
        }
      ]
    }
  ],
  "tasks": [
    {
      "id": "poll-now",
      "name": "Poll for new posts now",
      "prompt": "Run the hourly poll now, exactly as the scheduled run does: read the Roster, fetch posts newer than two hours per platform in one call per platform, merge into the Dashboard state with daslab_write_data, and append a `poll` line to the Run log.",
      "initialState": {},
      "tools": {
        "expected": [
          "daslab_read_cell",
          "daslab_write_data",
          "daslab_note_write"
        ]
      },
      "checks": [
        {
          "op": "contains",
          "path": "log.content",
          "substring": "poll",
          "ci": true,
          "why": "Proves the poll ran and logged."
        },
        {
          "op": "lte",
          "path": "$run.cost_usd",
          "value": 2.5,
          "expose": true,
          "severity": "fail",
          "why": "Task budget cap."
        }
      ]
    },
    {
      "id": "snapshot-now",
      "name": "Snapshot follower counts now",
      "prompt": "Run the daily follower snapshot now, exactly as the scheduled run does: read the Roster, fetch Instagram and TikTok profiles in one call per platform, update followers and history in the Dashboard state with daslab_write_data, and append a `snapshot` line to the Run log.",
      "initialState": {},
      "tools": {
        "expected": [
          "daslab_read_cell",
          "daslab_write_data",
          "daslab_note_write"
        ]
      },
      "checks": [
        {
          "op": "contains",
          "path": "log.content",
          "substring": "snapshot",
          "ci": true,
          "why": "Proves the snapshot ran and logged."
        },
        {
          "op": "lte",
          "path": "$run.cost_usd",
          "value": 5,
          "expose": true,
          "severity": "fail",
          "why": "Task budget cap."
        }
      ]
    },
    {
      "id": "add-creator",
      "name": "Add a creator to the roster",
      "prompt": "Add the creator named in the conversation to the **Roster** note as a new table row: Name, Instagram username, TikTok username, Facebook page URL, X handle, leaving unknown platforms empty. Then fetch that creator's profiles on the platforms given and add them to the Dashboard state (kols entry with handles and followers) with daslab_write_data. Confirm with the row you added.",
      "initialState": {},
      "tools": {
        "expected": [
          "daslab_note_write",
          "daslab_write_data"
        ]
      },
      "checks": [
        {
          "op": "lte",
          "path": "$run.cost_usd",
          "value": 1,
          "expose": true,
          "severity": "fail",
          "why": "Task budget cap."
        }
      ]
    }
  ],
  "members": [
    {
      "email": "labhausxyz@gmail.com",
      "role": "owner"
    }
  ],
  "meta": {
    "author": "Daslab",
    "license": "MIT",
    "source": "blueprint — edit the Roster table, optionally bind --input brand_handles=yourbrand,#yourcampaign. No accounts to connect: the social feed runs on Daslab's data feed, metered on the scene's plan."
  }
}
