{
  "version": 1,
  "id": "scn_supplier_orders_blueprint",
  "slug": "supplier-orders",
  "name": "Supplier orders from LINE groups",
  "icon": "shippingbox",
  "tint": "#3F6E8C",
  "meta": {
    "author": "Mirko Kiefer",
    "authors": [
      {
        "name": "Mirko Kiefer",
        "about": "Daslab"
      }
    ],
    "credit": "First built for a café chain in Bangkok",
    "license": "MIT"
  },
  "settings": {
    "description": "Every order your branches agree with suppliers in LINE groups, on one board: what was ordered, for which branch, when it comes, and whether it arrived.",
    "welcomeSuggestions": [
      {
        "label": "What needs a reply?",
        "prompt": "Which suppliers are waiting on us, and what do they need from us?"
      },
      {
        "label": "What arrives tomorrow?",
        "prompt": "List what arrives tomorrow, by branch."
      },
      {
        "label": "Bring in my groups",
        "prompt": "How do I bring my supplier LINE groups into this scene?"
      }
    ]
  },
  "assets": [
    {
      "id": "board",
      "type": "daslab/webview",
      "name": "Supplier board",
      "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>Suppliers</title>\n<style>\n  .branches { display: flex; flex-wrap: wrap; gap: 8px; margin: 20px 0 0; }\n  .branches button { flex: none; min-height: 40px; padding: 6px 14px; border-radius: 999px; color: var(--text-secondary); }\n  .branches button[aria-pressed=\"true\"] { color: var(--text); border-color: var(--text); font-weight: 600; }\n  html[data-cell=\"tile\"] .branches { display: none; }\n  html:not([data-cell=\"tile\"]) .stats dd:first-of-type { font-size: calc(40px * var(--brand-display-scale)); }\n\n  .where { font-weight: 500; color: var(--text-secondary); }\n  .ask { margin: 10px 0 0; font-size: 18px; line-height: 1.4; color: var(--text); }\n  .next { margin: 6px 0 0; font-size: 14px; color: var(--text-secondary); }\n  .hint { margin: 10px 0 0; font-size: 13px; color: var(--text-dim); }\n  .again { margin-top: 10px; padding: 0; min-height: 0; border: 0; background: none; font-size: 13px; color: var(--text-dim); text-decoration: underline; }\n\n  .chat { list-style: none; margin: 12px 0 0; padding: 0 0 0 12px; border-left: 2px solid var(--border); display: grid; gap: 8px; }\n  .chat li { font-size: 14px; line-height: 1.45; overflow-wrap: anywhere; }\n  .chat .who { display: block; font-size: 12px; color: var(--text-dim); }\n  .chat img { width: 64px; height: 64px; object-fit: cover; border-radius: 6px; border: 1px solid var(--border); cursor: zoom-in; }\n\n  .lines { list-style: none; margin: 12px 0 0; padding: 0; }\n  .lines li { display: grid; grid-template-columns: 96px 1fr; gap: 12px; padding: 8px 0; border-top: 1px solid var(--border); font-size: 15px; line-height: 1.35; }\n  .lines .qty { font-weight: 600; font-variant-numeric: tabular-nums; }\n  .more summary { cursor: pointer; padding: 8px 0 0; font-size: 14px; color: var(--text-secondary); }\n\n  .photos { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin-top: 12px; }\n  .photos img { width: 100%; aspect-ratio: 3 / 4; }\n  .hero { width: 100%; height: auto; max-height: 70vh; margin-bottom: 12px; }\n  img.hero { object-fit: contain; background: var(--bg-surface); }\n  .photos img, .hero, .proof img { display: block; object-fit: cover; border-radius: 10px; border: 1px solid var(--border); cursor: zoom-in; }\n  .proof { display: flex; align-items: center; gap: 12px; margin-top: 12px; }\n  .proof img { width: 96px; height: 96px; flex: none; }\n\n  .acts { display: flex; gap: 8px; margin-top: 14px; }\n  .acts button { flex: 1; min-height: 52px; font-size: 17px; }\n  .done-by { margin-top: 12px; font-size: 15px; color: var(--text); }\n  .small { min-height: 36px; font-size: 14px; }\n\n  .today-row { display: flex; align-items: center; gap: 12px; }\n  .today-row img { width: 56px; height: 56px; flex: none; object-fit: cover; border-radius: 8px; border: 1px solid var(--border); cursor: zoom-in; }\n  .today-row .nophoto { width: 56px; height: 56px; flex: none; border-radius: 8px; border: 1px dashed var(--border); }\n  .today-row > div { flex: 1; min-width: 0; }\n  .quiet-names { margin: 8px 0 0; color: var(--text-secondary); }\n\n  .lightbox { position: fixed; inset: 0; z-index: 20; display: none; align-items: center; justify-content: center; padding: 16px; background: var(--bg); }\n  .lightbox.open { display: flex; }\n  .lightbox img { max-width: 100%; max-height: 100%; border-radius: 8px; }\n  .toast { position: fixed; left: 16px; right: 16px; bottom: 16px; z-index: 21; padding: 14px 16px; border-radius: 12px; background: var(--bg-surface); border: 1px solid var(--border); font-size: 15px; display: none; }\n  .toast.show { display: block; }\n</style>\n</head>\n<body>\n<main>\n  <header>\n    <p class=\"kicker\" id=\"kicker\">Suppliers</p>\n    <h1 id=\"headline\">Suppliers</h1>\n    <p class=\"dek\" id=\"dek\"></p>\n  </header>\n\n  <nav class=\"branches\" id=\"branches\" aria-label=\"Branch\"></nav>\n\n  <dl class=\"stats\" id=\"stats\">\n    <div id=\"stat-reply\"><dt>Need a reply</dt><dd id=\"n-reply\" translate=\"no\">0</dd><dd id=\"s-reply\"></dd></div>\n    <div id=\"stat-today\"><dt>Arriving today</dt><dd id=\"n-today\" translate=\"no\">0</dd><dd id=\"s-today\"></dd></div>\n    <div id=\"stat-check\"><dt>To check off</dt><dd id=\"n-check\" translate=\"no\">0</dd><dd id=\"s-check\"></dd></div>\n    <div id=\"stat-between\"><dt>Between branches</dt><dd id=\"n-between\" translate=\"no\">0</dd><dd id=\"s-between\"></dd></div>\n  </dl>\n\n  <section id=\"needs\" hidden><h2>Needs a reply</h2><ul class=\"items\" id=\"list-needs\"></ul></section>\n  <section id=\"today\" hidden><h2>Arriving today</h2><ul class=\"items\" id=\"list-today\"></ul></section>\n  <section id=\"check\" hidden><h2>Did these arrive?</h2><ul class=\"items\" id=\"list-check\"></ul></section>\n  <section id=\"between\" hidden><h2>Between branches</h2><ul class=\"items\" id=\"list-between\"></ul></section>\n  <section id=\"received\" hidden><h2>Received today</h2><p id=\"received-who\"></p><ul class=\"items\" id=\"list-received\"></ul></section>\n  <section id=\"coming\" hidden><h2>Coming up</h2><ul class=\"items\" id=\"list-coming\"></ul></section>\n  <section id=\"quiet\" hidden><h2>Quiet</h2><p class=\"quiet-names\" id=\"quiet-names\"></p></section>\n\n  <footer id=\"footer\"></footer>\n</main>\n\n<div class=\"lightbox\" id=\"lightbox\" role=\"dialog\" aria-label=\"Photo\"><img id=\"lightbox-img\" alt=\"\"></div>\n<div class=\"toast\" id=\"toast\" role=\"status\"></div>\n\n<script>\n(() => {\n  // The verbs live on the tables, so a job or an agent can do everything this\n  // page does: daslab_cell_action on Receiving (received, not_arrived,\n  // add_photo, undo, take, done, still_open) or on Supplier reads (read_chats).\n  // The tables are found by name, so every copy of this scene reaches its own.\n  const TABLES = { receiving: 'receiving', reads: 'supplier_reads' };\n  const TZ = 'Asia/Bangkok';\n  const DATA = window.__DASLAB_WIDGET_DATA__ || {};\n  const BRANCHES = [['ALL', 'All'], ...(Array.isArray(DATA.branches) ? DATA.branches : [])];\n  const BRANCH_NAME = Object.fromEntries(BRANCHES.slice(1));\n\n  const META = window.__DASLAB_WIDGET_META__ || {};\n  const VIEWER = (window.__CELL__ || {}).viewer || null;\n  const suppliers = Array.isArray(DATA.suppliers) ? DATA.suppliers : [];\n  const orders = Array.isArray(DATA.orders) ? DATA.orders : [];\n  const requests = Array.isArray(DATA.requests) ? DATA.requests : [];\n  const quiet = Array.isArray(DATA.quiet) ? DATA.quiet : [];\n  const me = VIEWER ? String(VIEWER.name || 'You').split(' ')[0] : 'You';\n\n  // ── Bangkok days ────────────────────────────────────────────────────\n  const dayKeyOf = (d) => new Intl.DateTimeFormat('en-CA', { timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d);\n  const TODAY = dayKeyOf(new Date());\n  const shift = (key, n) => { const [y, m, d] = key.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d + n)).toISOString().slice(0, 10); };\n  const longDay = (key) => { const [y, m, d] = key.split('-').map(Number); return new Date(Date.UTC(y, m - 1, d)).toLocaleDateString('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone: 'UTC' }); };\n  function dayName(key) {\n    if (!key) return 'No date yet';\n    if (key === TODAY) return 'Today';\n    if (key === shift(TODAY, 1)) return 'Tomorrow';\n    if (key === shift(TODAY, -1)) return 'Yesterday';\n    return longDay(key);\n  }\n  function whenSaid(iso) {\n    if (!iso) return '';\n    const d = new Date(iso);\n    const k = dayKeyOf(d);\n    const t = d.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit', timeZone: TZ });\n    if (k === TODAY) return t;\n    if (k === shift(TODAY, -1)) return 'yesterday ' + t;\n    return d.toLocaleDateString('en-GB', { weekday: 'short', timeZone: TZ }) + ' ' + t;\n  }\n  const firstName = (n) => String(n || '').split(' ')[0];\n\n  // ── Marks: what people did. The table is the truth; a tap shows at once\n  //    and the table catches up a moment later ─────────────────────────\n  const pending = new Map();\n  const markOf = (x) => pending.has(x.id) ? pending.get(x.id) : (x.mark || null);\n\n  function orderPlace(o) {\n    const m = markOf(o);\n    if (m && m.state === 'received') return m.at && dayKeyOf(new Date(m.at)) === TODAY ? 'received' : 'old';\n    if (!o.delivery || o.delivery > TODAY) return 'coming';\n    if (o.delivery === TODAY) return 'today';\n    if (o.delivery >= shift(TODAY, -7)) return 'check';\n    return 'old';\n  }\n  function requestState(r) {\n    const m = markOf(r);\n    if (m && m.state) return m.state;\n    return r.status === 'done' ? 'looks-done' : 'open';\n  }\n  const supplierBranches = (s) => [...new Set([...(s.branches || []), ...orders.filter((o) => o.handle === s.handle).map((o) => o.branch)])];\n\n  let branch = 'ALL';\n  const branchKey = VIEWER ? 'asset:branch:' + VIEWER.id : null;\n  try { const saved = branchKey && JSON.parse(localStorage.getItem(branchKey) || 'null'); if (saved && (saved === 'ALL' || BRANCH_NAME[saved])) branch = saved; } catch (e) {}\n  const shows = (branches) => branch === 'ALL' || branches.length === 0 || branches.includes(branch);\n\n  // ── Doing things: every button is one of the tables' actions ─────────\n  let sdk = null;\n  const getSdk = async () => sdk || (sdk = (await window.daslabReady)());\n\n  let tableIds = null;\n  async function tableId(table) {\n    const d = await getSdk();\n    if (!tableIds) {\n      const r = await d.call('daslab_table_list', {});\n      const listed = JSON.parse(r.content[0].text).tables || [];\n      tableIds = Object.fromEntries(listed.map((t) => [t.name, t.asset_id]));\n    }\n    return tableIds[TABLES[table]];\n  }\n  async function act(table, action, inputs) {\n    const d = await getSdk();\n    const cell = await tableId(table);\n    if (!cell) throw new Error('No ' + TABLES[table] + ' table in this scene');\n    return d.call('daslab_cell_action', { cell_id: cell, action, inputs, request_id: 'v' + Date.now() + Math.random().toString(36).slice(2, 8) });\n  }\n  function mark(x, action, state, inputs) {\n    const before = pending.has(x.id) ? pending.get(x.id) : undefined;\n    const prior = markOf(x) || {};\n    pending.set(x.id, { ...prior, state, by: me, at: new Date().toISOString() });\n    redraw(x);\n    act('receiving', action, inputs).then(() => toast('Saved')).catch(() => {\n      before === undefined ? pending.delete(x.id) : pending.set(x.id, before);\n      redraw(x);\n      toast(\"Couldn't save. Please try again.\");\n    });\n  }\n\n  async function addPhoto(x) {\n    const input = document.createElement('input');\n    input.type = 'file';\n    input.accept = 'image/*';\n    input.style.display = 'none';\n    document.body.append(input);\n    input.addEventListener('change', async () => {\n      const file = input.files && input.files[0];\n      input.remove();\n      if (!file) return;\n      toast('Adding the photo…', true);\n      try {\n        const blob = await shrink(file);\n        const local = URL.createObjectURL(blob);\n        const url = await upload(blob);\n        pending.set(x.id, { ...(markOf(x) || {}), photo: url, localPhoto: local });\n        redraw(x);\n        await act('receiving', 'add_photo', { id: x.id, photo: url });\n        toast('Photo added');\n      } catch (e) {\n        toast(\"Couldn't add the photo. Please try again.\");\n      }\n    });\n    input.click();\n  }\n  async function shrink(file) {\n    try {\n      const bmp = await createImageBitmap(file);\n      const scale = Math.min(1, 1600 / Math.max(bmp.width, bmp.height));\n      const c = document.createElement('canvas');\n      c.width = Math.round(bmp.width * scale); c.height = Math.round(bmp.height * scale);\n      c.getContext('2d').drawImage(bmp, 0, 0, c.width, c.height);\n      return await new Promise((res) => c.toBlob((b) => res(b || file), 'image/jpeg', 0.82));\n    } catch (e) { return file; }\n  }\n  async function upload(blob) {\n    const buf = await blob.arrayBuffer();\n    const sha = [...new Uint8Array(await crypto.subtle.digest('SHA-256', buf))].map((b) => b.toString(16).padStart(2, '0')).join('');\n    const d = await getSdk();\n    const r = await d.call('daslab_request_upload', { filename: 'delivery.jpg', sha256: sha });\n    const info = JSON.parse(r.content[0].text);\n    if (!info.exists) {\n      const put = await fetch(info.upload_url, { method: 'PUT', body: blob, headers: { 'Content-Type': blob.type || 'image/jpeg' } });\n      if (!put.ok) throw new Error('upload ' + put.status);\n    }\n    return info.file_url;\n  }\n  async function readAgain(s, button) {\n    button.disabled = true;\n    button.textContent = 'Reading the chat again…';\n    try { await act('reads', 'read_chats', { chat: s.handle }); button.textContent = 'Done. Open the board again to see it.'; }\n    catch (e) { button.disabled = false; button.textContent = \"Couldn't read it. Try again\"; }\n  }\n\n  // ── Building blocks ─────────────────────────────────────────────────\n  function el(tag, attrs, kids) {\n    const n = document.createElement(tag);\n    for (const [k, v] of Object.entries(attrs || {})) if (v !== null && v !== undefined) n.setAttribute(k, v);\n    for (const kid of [].concat(kids === undefined ? [] : kids)) if (kid !== null && kid !== undefined && kid !== false) n.append(kid);\n    return n;\n  }\n  const slug = (s) => String(s).replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '').toLowerCase();\n  const name = (text) => el('span', { translate: 'no' }, text);\n  const state = (cls, text) => el('span', { class: 'state ' + cls }, text);\n  const title = (who, where, chip) => el('h3', {}, [el('span', {}, [name(who), where ? el('span', { class: 'where' }, ' · ' + where) : null]), chip]);\n  const photo = (url, cls) => el('img', { src: url, class: cls || null, alt: 'Photo', loading: 'lazy', 'data-full': url });\n  function button(text, cls, onClick) {\n    const b = el('button', { type: 'button', class: cls }, text);\n    b.addEventListener('click', (e) => onClick(e.currentTarget));\n    return b;\n  }\n  function lines(items, all) {\n    const row = (it) => el('li', {}, [el('span', { class: 'qty', translate: 'no' }, it.qty || ''), el('span', {}, it.name || '')]);\n    const list = el('ul', { class: 'lines' }, (all ? items : items.slice(0, 3)).map(row));\n    if (all || items.length <= 3) return list;\n    const rest = items.slice(3);\n    return el('div', {}, [list, el('details', { class: 'more' }, [el('summary', {}, rest.length + ' more'), el('ul', { class: 'lines' }, rest.map(row))])]);\n  }\n  const supplierOf = (handle) => suppliers.find((s) => s.handle === handle);\n  const againButton = (s) => s ? button('Something wrong? Read the chat again', 'again', (b) => readAgain(s, b)) : null;\n\n  // ── Cards ───────────────────────────────────────────────────────────\n  function needsCard(s) {\n    const recent = (s.recent || []).slice(-3).map((m) => el('li', {}, [\n      el('span', { class: 'who' }, [name(m.from), ' · ' + whenSaid(m.at)]),\n      m.photo ? photo(m.photo) : m.text,\n    ]));\n    return el('li', { id: 'needs-' + slug(s.handle), 'data-branches': supplierBranches(s).join(' ') }, [\n      title(s.name, s.category, state('warn', 'Needs a reply')),\n      el('p', {}, 'Last message ' + whenSaid(s.last && s.last.at)),\n      el('p', { class: 'ask' }, s.ask),\n      el('ol', { class: 'chat' }, recent),\n      el('p', { class: 'hint' }, ['Reply in the ', name(s.name), ' group on LINE.']),\n      againButton(s),\n    ]);\n  }\n\n  function orderChip(o, m) {\n    if (m && m.state === 'missing') return state('alert', 'Not arrived');\n    if (o.status === 'confirmed') return state('ok', 'Confirmed');\n    if (o.status === 'problem') return state('warn', 'Problem');\n    return state('info', 'Not confirmed yet');\n  }\n\n  function orderCard(o, place) {\n    const m = markOf(o);\n    const id = 'order-' + slug(o.id);\n    if (place === 'received') {\n      const shot = m.localPhoto || m.photo;\n      return el('li', { id, 'data-branches': o.branch }, [\n        el('div', { class: 'today-row' }, [\n          shot ? photo(shot) : el('span', { class: 'nophoto', 'aria-hidden': 'true' }),\n          el('div', {}, [\n            title(o.supplier, BRANCH_NAME[o.branch], null),\n            el('p', {}, ['Received by ', name(firstName(m.by)), ' at ' + whenSaid(m.at)]),\n          ]),\n        ]),\n        el('div', { class: 'acts' }, [\n          shot ? null : button('Add a photo', 'primary', () => addPhoto(o)),\n          button('Undo', 'small', () => mark(o, 'undo', '', { id: o.id })),\n        ]),\n      ]);\n    }\n    const when = place === 'coming' ? dayName(o.delivery) : 'Delivery ' + dayName(o.delivery).toLowerCase();\n    const kids = [\n      title(o.supplier, BRANCH_NAME[o.branch], orderChip(o, m)),\n      el('p', {}, [o.category, ' · ', when, ' · ordered ' + whenSaid(o.placed_at)]),\n    ];\n    if (place !== 'coming' && (o.photos || []).length) kids.push(el('div', { class: 'photos' }, o.photos.map((u) => photo(u))));\n    kids.push(lines(o.items || [], place === 'today'));\n    if (place === 'today' || place === 'check') {\n      if (m && m.state === 'missing') {\n        kids.push(el('p', { class: 'done-by' }, ['Reported not arrived by ', name(firstName(m.by)), ' at ' + whenSaid(m.at)]));\n        kids.push(el('div', { class: 'acts' }, [button('It came after all', 'primary', () => mark(o, 'received', 'received', { order: o.id }))]));\n      } else {\n        kids.push(el('div', { class: 'acts' }, [\n          button('Received', 'primary', () => mark(o, 'received', 'received', { order: o.id })),\n          button('Not arrived', null, () => mark(o, 'not_arrived', 'missing', { order: o.id })),\n        ]));\n      }\n    }\n    if (o.status !== 'confirmed') kids.push(againButton(supplierOf(o.handle)));\n    return el('li', { id, 'data-branches': o.branch }, kids);\n  }\n\n  function requestCard(r) {\n    const m = markOf(r);\n    const st = requestState(r);\n    const chip = st === 'mine' ? state('info', firstName(m.by) + ' is doing it')\n      : st === 'looks-done' ? state('info', 'Looks done')\n      : state('warn', r.kind === 'unclaimed' ? 'Whose is this?' : 'Nobody on it');\n    const kids = [];\n    if (r.photo) kids.push(photo(r.photo, 'hero'));\n    kids.push(title(BRANCH_NAME[r.branch] || 'Any branch', null, chip));\n    kids.push(el('p', {}, [name(r.from || ''), r.from ? ' · ' : '', whenSaid(r.placed_at)]));\n    kids.push(el('p', { class: 'ask' }, r.what));\n    if (r.next && st !== 'looks-done') kids.push(el('p', { class: 'next' }, 'Next: ' + r.next));\n    const take = () => mark(r, 'take', 'mine', { request: r.id });\n    const done = () => mark(r, 'done', 'done', { request: r.id });\n    kids.push(el('div', { class: 'acts' }, st === 'mine'\n      ? [button('Done', 'primary', done), button('Not me', null, () => mark(r, 'undo', '', { id: r.id }))]\n      : st === 'looks-done'\n      ? [button(\"Yes, it's done\", 'primary', done), button('Still open', null, () => mark(r, 'still_open', 'open', { request: r.id }))]\n      : [button(r.kind === 'unclaimed' ? \"It's mine\" : \"I'll do it\", 'primary', take), button('Already done', null, done)]));\n    return el('li', { id: 'request-' + slug(r.id), 'data-branches': r.branch || '' }, kids);\n  }\n\n  // ── Drawing: cards are built once; a tap rebuilds only the card it\n  //    touched, so translated text never flickers ─────────────────────\n  const LIST = {};\n  for (const id of ['needs', 'today', 'check', 'between', 'received', 'coming']) LIST[id] = document.getElementById('list-' + id);\n  const cardOf = new Map();\n  const byId = new Map([...orders, ...requests].map((x) => [x.id, x]));\n\n  function placeOrder(o) {\n    const place = orderPlace(o);\n    const old = cardOf.get(o.id);\n    if (old) old.remove();\n    cardOf.delete(o.id);\n    if (place === 'old') return;\n    const card = orderCard(o, place);\n    LIST[place].append(card);\n    cardOf.set(o.id, card);\n  }\n  function placeRequest(r) {\n    const old = cardOf.get(r.id);\n    if (old) old.remove();\n    cardOf.delete(r.id);\n    if (requestState(r) === 'done') return;\n    const card = requestCard(r);\n    LIST.between.append(card);\n    cardOf.set(r.id, card);\n  }\n  function redraw(x) {\n    orders.includes(x) ? placeOrder(x) : placeRequest(x);\n    refresh();\n  }\n\n  // ── What the page says, for the branch in view ──────────────────────\n  const visible = (list) => [...list.children].filter((li) => !li.hidden);\n  function refresh() {\n    for (const list of Object.values(LIST)) {\n      for (const li of list.children) li.hidden = !shows((li.getAttribute('data-branches') || '').split(' ').filter(Boolean));\n    }\n    const needs = suppliers.filter((s) => s.kind === 'supplier' && s.waiting_on === 'us' && shows(supplierBranches(s)));\n    const todayOrders = orders.filter((o) => orderPlace(o) === 'today' && shows([o.branch]));\n    const checkOrders = orders.filter((o) => orderPlace(o) === 'check' && shows([o.branch]));\n    const gotToday = orders.filter((o) => orderPlace(o) === 'received' && shows([o.branch]));\n    const openReqs = requests.filter((r) => requestState(r) !== 'done' && shows(r.branch ? [r.branch] : []));\n    const unowned = openReqs.filter((r) => requestState(r) === 'open');\n\n    for (const id of Object.keys(LIST)) document.getElementById(id).hidden = visible(LIST[id]).length === 0;\n\n    const tally = {};\n    for (const o of gotToday) { const who = firstName(markOf(o).by) || 'Someone'; tally[who] = (tally[who] || 0) + 1; }\n    setText('received-who', Object.entries(tally).map(([who, n]) => who + ' ' + n).join(' · '));\n\n    setStat('reply', needs.length, needs.map((s) => s.name).join(', ') || 'Nothing waiting');\n    setStat('today', todayOrders.length, todayOrders.map((o) => o.supplier).join(', ') || (gotToday.length ? 'All received' : 'No deliveries'));\n    setStat('check', checkOrders.length, checkOrders.length ? 'From before today' : 'All checked off');\n    setStat('between', openReqs.length, openReqs.length ? (unowned.length ? unowned.length + ' with nobody on it' : 'Someone is on each') : 'Nothing open');\n\n    const deliveries = (n) => n + (n === 1 ? ' delivery' : ' deliveries');\n    const parts = [];\n    if (todayOrders.length) parts.push(deliveries(todayOrders.length) + ' arriving today');\n    if (checkOrders.length) parts.push(deliveries(checkOrders.length) + ' from before to check off');\n    if (openReqs.length) parts.push(openReqs.length + (openReqs.length === 1 ? ' thing' : ' things') + ' open between branches');\n    let h1;\n    if (needs.length === 1) h1 = needs[0].name + ' needs a reply';\n    else if (needs.length > 1) h1 = needs.length + ' suppliers need a reply';\n    else if (parts.length) h1 = cap(parts.shift());\n    else h1 = 'Nothing waiting on you';\n    setText('headline', h1);\n    document.getElementById('headline').setAttribute('data-short', h1);\n    const done = gotToday.length ? deliveries(gotToday.length) + ' received today' : '';\n    const rest = parts.length ? cap(joinWords(parts)) + '.' : '';\n    setText('dek', DATA.sample\n      ? 'Sample data: an invented café with three branches. Bring in your own LINE groups to replace it (see Make this board yours).'\n      : [rest, done ? cap(done) + '.' : ''].filter(Boolean).join(' ') || 'Every order is confirmed or checked off.');\n    setText('kicker', 'Suppliers · ' + (branch === 'ALL' ? '' : BRANCH_NAME[branch] + ' · ') + longDay(TODAY));\n    for (const b of document.querySelectorAll('#branches button')) b.setAttribute('aria-pressed', b.dataset.branch === branch ? 'true' : 'false');\n  }\n  function setStat(id, n, sub) { setText('n-' + id, String(n)); setText('s-' + id, sub); }\n  function setText(id, text) { const n = document.getElementById(id); if (n.textContent !== text) n.textContent = text; }\n  const cap = (s) => s.charAt(0).toUpperCase() + s.slice(1);\n  const joinWords = (xs) => xs.length < 2 ? xs.join('') : xs.slice(0, -1).join(', ') + ' and ' + xs[xs.length - 1];\n\n  let toastTimer = null;\n  function toast(text, stay) {\n    const t = document.getElementById('toast');\n    t.textContent = text;\n    t.classList.add('show');\n    clearTimeout(toastTimer);\n    if (!stay) toastTimer = setTimeout(() => t.classList.remove('show'), 2200);\n  }\n\n  // ── First draw ──────────────────────────────────────────────────────\n  const nav = document.getElementById('branches');\n  for (const [code, label] of BRANCHES) {\n    const b = button(label, null, () => { branch = code; try { if (branchKey) localStorage.setItem(branchKey, JSON.stringify(code)); } catch (e) {} refresh(); });\n    b.dataset.branch = code;\n    nav.append(b);\n  }\n  for (const s of suppliers.filter((s) => s.kind === 'supplier' && s.waiting_on === 'us')) LIST.needs.append(needsCard(s));\n  const byDelivery = (a, b) => (a.delivery || '9999').localeCompare(b.delivery || '9999') || a.supplier.localeCompare(b.supplier);\n  for (const o of [...orders].sort(byDelivery)) placeOrder(o);\n  for (const r of requests) placeRequest(r);\n  if (quiet.length) {\n    document.getElementById('quiet-names').append('No messages yet from ', ...quiet.flatMap((q, i) => [i ? ', ' : '', name(q.name)]), '.');\n  }\n\n  const unread = suppliers.filter((s) => s.unread).map((s) => s.name);\n  const footer = document.getElementById('footer');\n  footer.append('Read from ' + (suppliers.length + quiet.length) + ' LINE groups · updated ' + whenSaid(META.computed_at));\n  if (META.state === 'failed' || META.stale) footer.append(' ', state('warn', 'Not updated since then'));\n  if (unread.length) footer.append(' · not read yet: ', ...unread.map((u) => name(u)));\n\n  refresh();\n  document.getElementById('quiet').hidden = quiet.length === 0;\n\n  // ── Photos open full screen ─────────────────────────────────────────\n  const box = document.getElementById('lightbox');\n  const boxImg = document.getElementById('lightbox-img');\n  document.addEventListener('click', (e) => {\n    const img = e.target.closest && e.target.closest('img[data-full]');\n    if (img) { boxImg.src = img.getAttribute('src'); box.classList.add('open'); return; }\n    if (e.target.closest && e.target.closest('#lightbox')) box.classList.remove('open');\n  });\n})();\n</script>\n</body>\n</html>\n",
        "programs": {
          "value": {
            "ttlSeconds": 86400,
            "description": "One card per chat, one row per order and one per request between branches, from each chat's stored reading and what people marked. No model runs here, so the same readings and marks always give the same board. An order's id is its chat, branch and the minute it was placed, so a mark stays on the same order.",
            "body": [
              {
                "id": "parts",
                "op": "core.let",
                "title": "The readings, the marks and the branches",
                "params": {
                  "be": {
                    "reads": "$cells.supplier_reads",
                    "marks": "$cells.receiving",
                    "branches": "$cells.branches"
                  }
                }
              },
              {
                "op": "core.transform",
                "title": "Build the board",
                "params": {
                  "jq": "def at16: if type == \"string\" and length >= 16 then .[0:16] else \"\" end;\n([ (.branches.rows // [])[] | (.data // .) | select((.code // \"\") != \"\") | [ .code, (.name // .code) ] ]) as $br\n| ([ (.marks.rows // [])[] | (.data // .) | select(.id != null and .id != \"\")\n     | { key: .id, value: { state: (.state // \"\"), by: (.by // \"\"), at: (.at // \"\"), photo: (.photo // \"\"), note: (.note // \"\") } } ] | from_entries) as $mk\n| ([ (.reads.rows // [])[] | (.data // .) | select(.handle != null) | . + { r: (if (.read // \"\") == \"\" then null else (.read | fromjson) end) } ]) as $rows\n| ([ $rows[] | select(.r != null and ((.r.recent // []) | length) > 0) ]) as $active\n| {\n    branches: $br,\n    sample: (($rows | length) > 0 and ([ $rows[] | .handle | startswith(\"sample_\") ] | all)),\n    suppliers: [ $active[] | { handle, name: (.name // .handle), category: (.category // \"\"), kind: (.kind // \"supplier\"),\n        message_count: (.r.message_count // 0), last: (.r.last // null), recent: (.r.recent // []), waiting_on: (.r.waiting_on // \"nobody\"),\n        ask: (.r.ask // \"\"), branches: (.r.branches // []), unread: false } ],\n    orders: [ $active[] | select((.kind // \"supplier\") == \"supplier\") | . as $g | (.r.orders // []) as $os | $os[] | . as $o\n      | ([ $os[] | .placed_at | select(. > ($o.placed_at // \"\")) ] | min) as $next | ($o.placed_at | at16) as $k\n      | ($g.handle + \":\" + $o.branch + \":\" + (if $k != \"\" then $k else $o.delivery end)) as $id\n      | { id: $id, handle: $g.handle, supplier: ($g.name // $g.handle), category: ($g.category // \"\"), branch: $o.branch,\n          placed_at: $o.placed_at, delivery: $o.delivery, status: $o.status, items: $o.items, note: ($o.note // \"\"),\n          photos: ([ ($g.r.photos // [])[] | select(.at >= ($o.placed_at // \"\") and ($next == null or .at < $next)) | .url ] | .[-3:]),\n          mark: ($mk[$id] // null) } ],\n    requests: [ $active[] | select(.kind == \"internal\") | . as $g | (.r.requests // [])[] | . as $q | ($g.handle + \":\" + ($q.placed_at | at16)) as $id\n      | { id: $id, kind: $q.kind, branch: $q.branch, what: $q.what, next: ($q.next // \"\"), status: $q.status, placed_at: $q.placed_at,\n          from: ([ ($g.r.recent // [])[] | select(.at == $q.placed_at) | .from ] | .[0] // \"\"),\n          photo: (([ ($g.r.photos // [])[] | select(.at == ($q.photo_at // \"\")) | .url ] | .[0]) // null),\n          mark: ($mk[$id] // null) } ],\n    quiet: [ $rows[] | select(.r == null or ((.r.recent // []) | length) == 0) | { handle, name: (.name // .handle), category: (.category // \"\") } ]\n  }\n",
                  "over": "$parts"
                }
              }
            ]
          }
        }
      },
      "layout": {
        "sortOrder": 0,
        "size": "large"
      }
    },
    {
      "id": "guide",
      "type": "daslab/note",
      "name": "Make this board yours",
      "fields": {
        "content": "# Make this board yours\n\nThis scene keeps every order your branches agree with suppliers in LINE groups: what was ordered, for which branch, when it comes, and whether it arrived. It opens with sample data: an invented café with three branches and four suppliers. Try the board before you connect anything.\n\n## 1. Bring in your LINE groups\n\nDaslab reads a group only after someone in it brings it in.\n\n1. Add Daslab's LINE account (**@daslab**) to a supplier group.\n2. In that group, type **/link**. Daslab answers with a link that works for 30 minutes, and only for people in the group.\n3. Open it, sign in with LINE, and pick this scene.\n\nDo the same for each supplier group, and for the group your branches use among themselves. Each one appears here as a chat. Daslab sees what is said from the day the group came in, nothing earlier.\n\n## 2. Name your branches and your team\n\n- **Branches**: replace the sample rows with your own, a short code (HQ, TL) and the names staff use in the chats. Every order is filed under one of these codes.\n- **Team**: your own people, as their names show in LINE. In a supplier group, everyone not on this list is taken to work for the supplier.\n\n## 3. Let it read\n\n**Supplier reads** keeps one reading per chat: who the chat is waiting on, its orders by branch with their delivery day, and open requests between branches. Its action **Read the chats** reads every chat with new messages since the last reading. On the board, *Something wrong? Read the chat again* reads one chat afresh.\n\nTo read as messages arrive, ask Daslab: *\"Run Read the chats on Supplier reads whenever a message comes in to this scene's LINE groups.\"*\n\nYou can correct a chat's kind (supplier or between branches) and what it supplies in **Supplier reads**; a later reading keeps your answer.\n\n## 4. Receive\n\nWhen a delivery comes, mark it on the board: received, not arrived, or still open, with a photo if you like. Marks are kept in **Receiving**, so a mark stays on its order whatever the chat says next.\n\n## What Daslab posts\n\nNothing. This board only reads. Writing into a group is a separate step, and every message waits for a person to send it.\n\n## Remove the sample\n\nDelete the rows whose chat starts with `sample_` in **Supplier reads**, and the sample rows in **Branches** and **Team**, once your own groups are in.\n"
      },
      "layout": {
        "sortOrder": 1,
        "size": "medium"
      }
    },
    {
      "id": "branches",
      "type": "daslab/table",
      "name": "Branches",
      "fields": {
        "columns": [
          {
            "id": "code",
            "name": "Code",
            "type": "text"
          },
          {
            "id": "name",
            "name": "Name",
            "type": "text"
          },
          {
            "id": "also_called",
            "name": "Also called",
            "type": "text"
          }
        ],
        "rows": [
          {
            "code": "HQ",
            "name": "Old Town",
            "also_called": "head office, main kitchen"
          },
          {
            "code": "RV",
            "name": "Riverside",
            "also_called": ""
          },
          {
            "code": "ST",
            "name": "Station",
            "also_called": "the kiosk"
          }
        ]
      },
      "layout": {
        "sortOrder": 2,
        "size": "medium"
      }
    },
    {
      "id": "team",
      "type": "daslab/table",
      "name": "Team",
      "fields": {
        "columns": [
          {
            "id": "line_name",
            "name": "Name in LINE",
            "type": "text"
          },
          {
            "id": "branch",
            "name": "Branch",
            "type": "text"
          }
        ],
        "rows": [
          {
            "line_name": "Ploy",
            "branch": "HQ"
          },
          {
            "line_name": "Nok",
            "branch": "RV"
          },
          {
            "line_name": "Tom",
            "branch": "ST"
          }
        ]
      },
      "layout": {
        "sortOrder": 3,
        "size": "medium"
      }
    },
    {
      "id": "reads",
      "type": "daslab/table",
      "name": "Supplier reads",
      "fields": {
        "columns": [
          {
            "id": "handle",
            "name": "Chat",
            "type": "text"
          },
          {
            "id": "name",
            "name": "Name",
            "type": "text"
          },
          {
            "id": "kind",
            "name": "Kind",
            "type": "select",
            "options": [
              "supplier",
              "internal"
            ]
          },
          {
            "id": "category",
            "name": "Supplies",
            "type": "text"
          },
          {
            "id": "read_through",
            "name": "Read through",
            "type": "text"
          },
          {
            "id": "read_at",
            "name": "Read at",
            "type": "text"
          },
          {
            "id": "read",
            "name": "Reading",
            "type": "text"
          }
        ],
        "programs": {
          "actions": {
            "read_chats": {
              "description": "Read the chats",
              "inputs": {
                "chat": ""
              },
              "body": [
                {
                  "id": "found",
                  "op": "daslab_get_scene_assets",
                  "title": "Find this scene's LINE chats",
                  "params": {
                    "type": "line/conversation",
                    "limit": 500
                  }
                },
                {
                  "id": "chats",
                  "op": "core.map",
                  "title": "Read each chat's messages",
                  "params": {
                    "as": "g",
                    "over": "$found.assets",
                    "maxConcurrency": 6
                  },
                  "do": [
                    {
                      "id": "c",
                      "op": "daslab_read_cell",
                      "title": "Read one chat",
                      "params": {
                        "full": true,
                        "cell_id": "$g.id"
                      }
                    },
                    {
                      "op": "core.let",
                      "params": {
                        "be": {
                          "handle": "$g.handle",
                          "name": "$g.name",
                          "messages": "$c.value.messages"
                        }
                      }
                    }
                  ]
                },
                {
                  "id": "seen",
                  "op": "daslab_table_rows",
                  "title": "Read what was read last time",
                  "params": {
                    "limit": 1000,
                    "asset_id": "$cell_id"
                  }
                },
                {
                  "id": "branches",
                  "op": "daslab_table_query",
                  "title": "Your branches",
                  "params": {
                    "sql": "SELECT code, name, also_called FROM branches"
                  }
                },
                {
                  "id": "team",
                  "op": "daslab_table_query",
                  "title": "Your people",
                  "params": {
                    "sql": "SELECT line_name, branch FROM team"
                  }
                },
                {
                  "id": "bundle",
                  "op": "core.let",
                  "params": {
                    "be": {
                      "chat": "$chat",
                      "rows": "$seen.rows",
                      "chats": "$chats",
                      "branches": "$branches.rows",
                      "team": "$team.rows"
                    }
                  }
                },
                {
                  "id": "due",
                  "op": "core.transform",
                  "title": "Find the chats to read",
                  "params": {
                    "jq": "def noise: (.text // \"\") as $t | $t == \"\" or $t == \"🔖 Sticker\" or ($t | startswith(\"↩\"));\ndef short($n): if . == \"📷 Image\" then \"[photo]\" else .[0:$n] end;\ndef bkk: sub(\"\\\\.[0-9]+Z$\"; \"Z\") | fromdateiso8601 + 25200 | strftime(\"%a %d %b %Y %H:%M\");\n(.chat // \"\") as $ask\n| (reduce ((.rows // [])[] | (.data // .)) as $r ({}; . + { ($r.handle // \"\"): $r })) as $seen\n| [ (.team // [])[] | .line_name | select(. != null and . != \"\") ] as $team\n| [ (.branches // [])[] | { code, name, also_called } | select(.code != null and .code != \"\") ] as $branches\n| [ (.chats // [])[] | . as $g | (($g.messages // []) | map(select(noise | not))) as $m | { g: $g, m: $m } ] as $all\n| {\n    due: [ $all[] | select((.m | length) > 0) | . as $x | ($x.m | .[-1].at) as $last\n      | select(($seen[$x.g.handle].read_through // \"\") != $last or $x.g.handle == $ask)\n      | { handle: $x.g.handle, name: $x.g.name, new: ($seen[$x.g.handle] == null), read_through: $last, count: ($x.m | length),\n          last: ($x.m | .[-1] | { at, from, text: ((.text // \"\") | short(140)) }),\n          recent: [ $x.m | .[-4:] | .[] | { at, from, text: ((.text // \"\") | short(200)), photo: (.file // null) } ],\n          photos: ([ $x.m[] | select(.file != null) | { at, from, url: .file } ] | .[-12:]),\n          input: ({ chat: $x.g.name, kind: ($seen[$x.g.handle].kind // \"\"), branches: $branches, team: $team,\n                    messages: [ $x.m | .[-18:] | reverse | .[] | { when: (.at | bkk), at, from, text: ((.text // \"\") | short(260)) } ] } | tojson) } ],\n    quiet: [ $all[] | select((.m | length) == 0 and $seen[.g.handle] == null) | { handle: .g.handle, name: .g.name } ]\n  }\n",
                    "over": "$bundle"
                  }
                },
                {
                  "id": "fresh",
                  "op": "core.map",
                  "title": "Read each chat",
                  "params": {
                    "as": "g",
                    "over": "$due.due",
                    "maxConcurrency": 6
                  },
                  "do": [
                    {
                      "id": "read",
                      "op": "llm_invoke",
                      "title": "Read one chat",
                      "params": {
                        "about": "$g.input",
                        "output": {
                          "type": "object",
                          "required": [
                            "chat_kind",
                            "supplies",
                            "waiting_on",
                            "ask",
                            "branches",
                            "orders",
                            "requests"
                          ],
                          "properties": {
                            "ask": {
                              "type": "string"
                            },
                            "orders": {
                              "type": "array",
                              "items": {
                                "type": "object",
                                "required": [
                                  "branch",
                                  "placed_at",
                                  "delivery",
                                  "status",
                                  "items"
                                ],
                                "properties": {
                                  "note": {
                                    "type": "string"
                                  },
                                  "items": {
                                    "type": "array",
                                    "items": {
                                      "type": "object",
                                      "required": [
                                        "qty",
                                        "name"
                                      ],
                                      "properties": {
                                        "qty": {
                                          "type": "string"
                                        },
                                        "name": {
                                          "type": "string"
                                        }
                                      }
                                    }
                                  },
                                  "branch": {
                                    "type": "string"
                                  },
                                  "status": {
                                    "enum": [
                                      "requested",
                                      "confirmed",
                                      "problem"
                                    ]
                                  },
                                  "delivery": {
                                    "type": "string"
                                  },
                                  "placed_at": {
                                    "type": "string"
                                  }
                                }
                              }
                            },
                            "branches": {
                              "type": "array",
                              "items": {
                                "type": "string"
                              }
                            },
                            "requests": {
                              "type": "array",
                              "items": {
                                "type": "object",
                                "required": [
                                  "kind",
                                  "branch",
                                  "what",
                                  "status",
                                  "next",
                                  "placed_at"
                                ],
                                "properties": {
                                  "kind": {
                                    "enum": [
                                      "need",
                                      "unclaimed",
                                      "question"
                                    ]
                                  },
                                  "next": {
                                    "type": "string"
                                  },
                                  "what": {
                                    "type": "string"
                                  },
                                  "branch": {
                                    "type": "string"
                                  },
                                  "status": {
                                    "enum": [
                                      "open",
                                      "done"
                                    ]
                                  },
                                  "photo_at": {
                                    "type": "string"
                                  },
                                  "placed_at": {
                                    "type": "string"
                                  }
                                }
                              }
                            },
                            "waiting_on": {
                              "enum": [
                                "us",
                                "supplier",
                                "nobody"
                              ]
                            },
                            "chat_kind": {
                              "enum": [
                                "supplier",
                                "internal"
                              ]
                            },
                            "supplies": {
                              "type": "string"
                            }
                          }
                        },
                        "prompt": "You read one LINE group of a business, for the board its team uses to keep supplier orders. The text is JSON: chat (the group's name), kind ('supplier', 'internal', or '' when not known yet), branches (the business's branches: code, name and what staff also call it), team (the business's own people as LINE names them; in a supplier group everyone else works for the supplier), and messages NEWEST FIRST. Each message's `when` is its day and time in Bangkok: use it for every date. A weekday in a message is the next such day after `when` (a message sent Thu 22 Oct that says Friday means Fri 23 Oct). `at` is the same moment in UTC, only for copying into placed_at. Thai dates may use Buddhist-era years: 69 or 2569 means 2026. An order that names no branch is for the first branch listed. A branch is always one of the codes in branches.\n\nAnswer:\nchat_kind: 'supplier' when the group is the business ordering from one supplier; 'internal' when the business's own people talk between branches and the head office. When kind is given, repeat it.\nsupplies: what the supplier supplies in at most three English words ('Milk', 'Paper packaging'), or '' for an internal group.\nwaiting_on: who must act next. 'us' when the supplier asked a question, raised a problem or asked for a correction that nobody in the team has answered since; 'supplier' when the team placed or changed an order the supplier has not acknowledged since; otherwise 'nobody'. In an internal group, 'us' while any request is open.\nask: one plain English sentence of at most 90 characters. If waiting_on is 'us', say what the team must answer and for which branch. Otherwise say where things stand. Write dates as weekday and day ('Fri 23 Oct').\nbranches: every branch that orders in, is asked about in, or is named in these messages (a staff name that carries a branch counts).\norders: supplier groups only, else []. One entry per order per branch, newest first, at most 4. When branches each ask for their own quantity in the same group, every branch is its own order. placed_at: the `at` of the message that placed that branch's order, copied exactly. delivery: the delivery date as YYYY-MM-DD when stated or clearly implied, else ''. status: 'confirmed' when the supplier repeated, confirmed or accepted that order or gave its delivery day; 'problem' when the supplier pushed back on it (minimum not met, short, quantity unclear); otherwise 'requested'. items: every line as {qty, name}, taken from the supplier's confirmation when the supplier sent a list, otherwise from the team's order. qty is the amount with its unit ('5 kg', '3 slabs', '1 pack', '5 gallons'); name is the product alone, always translated to short English even when the message is in Thai ('Milk', 'Pork belly', 'Ground pork shoulder').\nrequests: internal groups only, else []. One entry per topic, newest first, at most 5. kind: 'need' (a branch needs an item), 'unclaimed' (something arrived and nobody knows who ordered it), 'question'. branch: the branch it is for, or ''. what: one plain English sentence of at most 80 characters saying what it is; when only a photo shows the item, say what happened ('A delivery arrived and nobody knows who ordered it'). status: 'done' only when the matter itself is settled: the item was sent, bought or found for that branch, or whoever ordered an unclaimed delivery came forward. A reply such as 'we have none', 'I will look' or 'not mine' leaves it 'open'. next: what has to happen next in at most 60 characters ('Buy pump heads: head office has none'), or '' when done. placed_at: the `at` of the message that raised it, copied exactly. photo_at: the `at` of the photo that shows it, or ''.\nUse only what the messages say.",
                        "fallback": {
                          "chat_kind": "supplier",
                          "supplies": "",
                          "ask": "",
                          "orders": [],
                          "branches": [],
                          "requests": [],
                          "waiting_on": "nobody",
                          "fell_back": true
                        }
                      }
                    },
                    {
                      "op": "core.let",
                      "params": {
                        "be": {
                          "read": "$read",
                          "handle": "$g.handle",
                          "name": "$g.name",
                          "new": "$g.new",
                          "read_through": "$g.read_through",
                          "last": "$g.last",
                          "recent": "$g.recent",
                          "photos": "$g.photos",
                          "count": "$g.count"
                        }
                      }
                    }
                  ]
                },
                {
                  "id": "stamped",
                  "op": "core.let",
                  "params": {
                    "be": {
                      "now": "$now.iso",
                      "fresh": "$fresh",
                      "quiet": "$due.quiet"
                    }
                  }
                },
                {
                  "id": "rows",
                  "op": "core.transform",
                  "title": "Shape one row per chat read",
                  "params": {
                    "jq": ".now as $now\n| [ .fresh[] | select((.read.fell_back // false) | not)\n    | { handle, read_through, read_at: $now,\n        read: ((.read + { last: .last, recent: .recent, photos: .photos, message_count: .count }) | tojson) }\n      + (if .new then { name, kind: (.read.chat_kind // \"supplier\"), category: (.read.supplies // \"\") } else {} end) ]\n  + [ .quiet[] | { handle, name, kind: \"supplier\", category: \"\", read_through: \"\", read_at: $now, read: \"\" } ]\n",
                    "over": "$stamped"
                  }
                },
                {
                  "op": "core.if",
                  "title": "Store the new readings",
                  "params": {
                    "cond": "size(rows) > 0"
                  },
                  "then": [
                    {
                      "op": "daslab_table_upsert",
                      "title": "Write one row per chat",
                      "params": {
                        "key": "handle",
                        "rows": "$rows",
                        "asset_id": "$cell_id"
                      }
                    }
                  ]
                }
              ]
            }
          }
        },
        "rows": [
          {
            "handle": "sample_dairy",
            "name": "Green Valley Dairy",
            "kind": "supplier",
            "category": "Milk and cream",
            "read_through": "2026-10-24T09:12:00.000Z",
            "read_at": "2026-10-24T09:13:00.000Z",
            "read": "{\"chat_kind\": \"supplier\", \"supplies\": \"Milk and cream\", \"waiting_on\": \"us\", \"ask\": \"Confirm how much milk Riverside needs for the next drop: it is under the 10 L minimum.\", \"branches\": [\"HQ\", \"RV\"], \"orders\": [{\"branch\": \"HQ\", \"placed_at\": \"2026-10-24T08:40:00.000Z\", \"delivery\": \"2026-10-26\", \"status\": \"confirmed\", \"items\": [{\"qty\": \"20 litres\", \"name\": \"Fresh milk\"}, {\"qty\": \"4 litres\", \"name\": \"Whipping cream\"}]}, {\"branch\": \"RV\", \"placed_at\": \"2026-10-24T08:55:00.000Z\", \"delivery\": \"2026-10-26\", \"status\": \"problem\", \"items\": [{\"qty\": \"6 litres\", \"name\": \"Fresh milk\"}], \"note\": \"Below the 10 litre minimum\"}], \"requests\": [], \"last\": {\"at\": \"2026-10-24T09:12:00.000Z\", \"from\": \"Green Valley (Fon)\", \"text\": \"Riverside is under our 10 L minimum. Add to 10 L or join the HQ drop?\"}, \"recent\": [{\"at\": \"2026-10-24T08:40:00.000Z\", \"from\": \"Ploy\", \"text\": \"HQ, next drop: milk 20 L, cream 4 L please\", \"photo\": null}, {\"at\": \"2026-10-24T08:55:00.000Z\", \"from\": \"Nok\", \"text\": \"Riverside milk 6 L for the same drop\", \"photo\": null}, {\"at\": \"2026-10-24T09:05:00.000Z\", \"from\": \"Green Valley (Fon)\", \"text\": \"HQ confirmed ✓\", \"photo\": null}, {\"at\": \"2026-10-24T09:12:00.000Z\", \"from\": \"Green Valley (Fon)\", \"text\": \"Riverside is under our 10 L minimum. Add to 10 L or join the HQ drop?\", \"photo\": null}], \"photos\": [], \"message_count\": 4}"
          },
          {
            "handle": "sample_bakery",
            "name": "Old Mill Bakery Supply",
            "kind": "supplier",
            "category": "Flour and butter",
            "read_through": "2026-10-24T02:30:00.000Z",
            "read_at": "2026-10-24T09:13:00.000Z",
            "read": "{\"chat_kind\": \"supplier\", \"supplies\": \"Flour and butter\", \"waiting_on\": \"nobody\", \"ask\": \"Flour and butter arrive at HQ this afternoon.\", \"branches\": [\"HQ\"], \"orders\": [{\"branch\": \"HQ\", \"placed_at\": \"2026-10-24T01:10:00.000Z\", \"delivery\": \"2026-10-24\", \"status\": \"confirmed\", \"items\": [{\"qty\": \"25 kg\", \"name\": \"Bread flour\"}, {\"qty\": \"5 kg\", \"name\": \"Butter\"}]}], \"requests\": [], \"last\": {\"at\": \"2026-10-24T02:30:00.000Z\", \"from\": \"Old Mill (Ben)\", \"text\": \"Noted, this afternoon to HQ.\"}, \"recent\": [{\"at\": \"2026-10-24T01:10:00.000Z\", \"from\": \"Ploy\", \"text\": \"For this afternoon please: bread flour 25 kg, butter 5 kg\", \"photo\": null}, {\"at\": \"2026-10-24T02:30:00.000Z\", \"from\": \"Old Mill (Ben)\", \"text\": \"Noted, this afternoon to HQ.\", \"photo\": null}], \"photos\": [], \"message_count\": 2}"
          },
          {
            "handle": "sample_cups",
            "name": "Paper Cup Co.",
            "kind": "supplier",
            "category": "Cups and lids",
            "read_through": "2026-10-23T10:00:00.000Z",
            "read_at": "2026-10-24T09:13:00.000Z",
            "read": "{\"chat_kind\": \"supplier\", \"supplies\": \"Cups and lids\", \"waiting_on\": \"supplier\", \"ask\": \"Station ordered 2 cartons of 12 oz cups; no reply yet.\", \"branches\": [\"ST\"], \"orders\": [{\"branch\": \"ST\", \"placed_at\": \"2026-10-23T10:00:00.000Z\", \"delivery\": \"\", \"status\": \"requested\", \"items\": [{\"qty\": \"2 cartons\", \"name\": \"12 oz cups\"}, {\"qty\": \"1 carton\", \"name\": \"Lids\"}]}], \"requests\": [], \"last\": {\"at\": \"2026-10-23T10:00:00.000Z\", \"from\": \"Tom\", \"text\": \"Station: 2 cartons 12oz cups + 1 carton lids please\"}, \"recent\": [{\"at\": \"2026-10-23T10:00:00.000Z\", \"from\": \"Tom\", \"text\": \"Station: 2 cartons 12oz cups + 1 carton lids please\", \"photo\": null}], \"photos\": [], \"message_count\": 1}"
          },
          {
            "handle": "sample_branches",
            "name": "Between branches",
            "kind": "internal",
            "category": "",
            "read_through": "2026-10-24T08:20:00.000Z",
            "read_at": "2026-10-24T09:13:00.000Z",
            "read": "{\"chat_kind\": \"internal\", \"supplies\": \"\", \"waiting_on\": \"us\", \"ask\": \"Station needs oat milk; nobody has sent any yet.\", \"branches\": [\"ST\", \"HQ\"], \"orders\": [], \"requests\": [{\"kind\": \"need\", \"branch\": \"ST\", \"what\": \"Station is out of oat milk.\", \"status\": \"open\", \"next\": \"Send 6 cartons from HQ\", \"placed_at\": \"2026-10-24T07:50:00.000Z\", \"photo_at\": \"\"}], \"last\": {\"at\": \"2026-10-24T08:20:00.000Z\", \"from\": \"Ploy\", \"text\": \"Checking the store room\"}, \"recent\": [{\"at\": \"2026-10-24T07:50:00.000Z\", \"from\": \"Tom\", \"text\": \"Station out of oat milk, can HQ send some?\", \"photo\": null}, {\"at\": \"2026-10-24T08:20:00.000Z\", \"from\": \"Ploy\", \"text\": \"Checking the store room\", \"photo\": null}], \"photos\": [], \"message_count\": 2}"
          },
          {
            "handle": "sample_eggs",
            "name": "Sunrise Eggs",
            "kind": "supplier",
            "category": "Eggs",
            "read_through": "",
            "read_at": "2026-10-24T09:13:00.000Z",
            "read": ""
          }
        ]
      },
      "layout": {
        "sortOrder": 4,
        "size": "medium"
      }
    },
    {
      "id": "receiving",
      "type": "daslab/table",
      "name": "Receiving",
      "fields": {
        "columns": [
          {
            "id": "id",
            "name": "Id",
            "type": "text"
          },
          {
            "id": "state",
            "name": "State",
            "type": "text"
          },
          {
            "id": "by",
            "name": "By",
            "type": "text"
          },
          {
            "id": "at",
            "name": "At",
            "type": "text"
          },
          {
            "id": "photo",
            "name": "Photo",
            "type": "text"
          },
          {
            "id": "note",
            "name": "Note",
            "type": "text"
          }
        ],
        "programs": {
          "actions": {
            "done": {
              "body": [
                {
                  "id": "rows",
                  "op": "core.let",
                  "title": "Who finished it, and when",
                  "params": {
                    "be": [
                      {
                        "at": "$now.iso",
                        "by": "$runner.name",
                        "id": "$request",
                        "note": "$note",
                        "state": "done"
                      }
                    ]
                  },
                  "verify": [
                    "rows[0].id != ''"
                  ]
                },
                {
                  "op": "daslab_table_upsert",
                  "title": "Mark it done",
                  "params": {
                    "key": "id",
                    "rows": "$rows",
                    "asset_id": "$cell_id"
                  }
                }
              ],
              "inputs": {
                "note": "",
                "request": ""
              },
              "description": "Done"
            },
            "take": {
              "body": [
                {
                  "id": "rows",
                  "op": "core.let",
                  "title": "Who takes it, and when",
                  "params": {
                    "be": [
                      {
                        "at": "$now.iso",
                        "by": "$runner.name",
                        "id": "$request",
                        "state": "mine"
                      }
                    ]
                  },
                  "verify": [
                    "rows[0].id != ''"
                  ]
                },
                {
                  "op": "daslab_table_upsert",
                  "title": "Mark it taken",
                  "params": {
                    "key": "id",
                    "rows": "$rows",
                    "asset_id": "$cell_id"
                  }
                }
              ],
              "inputs": {
                "request": ""
              },
              "description": "Taken"
            },
            "undo": {
              "body": [
                {
                  "id": "rows",
                  "op": "core.let",
                  "title": "Clear the mark, keep who cleared it",
                  "params": {
                    "be": [
                      {
                        "at": "$now.iso",
                        "by": "$runner.name",
                        "id": "$id",
                        "state": ""
                      }
                    ]
                  },
                  "verify": [
                    "rows[0].id != ''"
                  ]
                },
                {
                  "op": "daslab_table_upsert",
                  "title": "Undo the mark",
                  "params": {
                    "key": "id",
                    "rows": "$rows",
                    "asset_id": "$cell_id"
                  }
                }
              ],
              "inputs": {
                "id": ""
              },
              "description": "Undone"
            },
            "received": {
              "body": [
                {
                  "id": "rows",
                  "op": "core.let",
                  "title": "Who received it, and when",
                  "params": {
                    "be": [
                      {
                        "at": "$now.iso",
                        "by": "$runner.name",
                        "id": "$order",
                        "note": "$note",
                        "state": "received"
                      }
                    ]
                  },
                  "verify": [
                    "rows[0].id != ''"
                  ]
                },
                {
                  "op": "daslab_table_upsert",
                  "title": "Mark it received",
                  "params": {
                    "key": "id",
                    "rows": "$rows",
                    "asset_id": "$cell_id"
                  }
                }
              ],
              "inputs": {
                "note": "",
                "order": ""
              },
              "description": "Received"
            },
            "add_photo": {
              "body": [
                {
                  "id": "rows",
                  "op": "core.let",
                  "title": "The photo for this delivery or request",
                  "params": {
                    "be": [
                      {
                        "id": "$id",
                        "photo": "$photo"
                      }
                    ]
                  },
                  "verify": [
                    "rows[0].id != ''",
                    "rows[0].photo != ''"
                  ]
                },
                {
                  "op": "daslab_table_upsert",
                  "title": "Keep the photo",
                  "params": {
                    "key": "id",
                    "rows": "$rows",
                    "asset_id": "$cell_id"
                  }
                }
              ],
              "inputs": {
                "id": "",
                "photo": ""
              },
              "description": "Photo added"
            },
            "still_open": {
              "body": [
                {
                  "id": "rows",
                  "op": "core.let",
                  "title": "Who says it is still open",
                  "params": {
                    "be": [
                      {
                        "at": "$now.iso",
                        "by": "$runner.name",
                        "id": "$request",
                        "state": "open"
                      }
                    ]
                  },
                  "verify": [
                    "rows[0].id != ''"
                  ]
                },
                {
                  "op": "daslab_table_upsert",
                  "title": "Mark it still open",
                  "params": {
                    "key": "id",
                    "rows": "$rows",
                    "asset_id": "$cell_id"
                  }
                }
              ],
              "inputs": {
                "request": ""
              },
              "description": "Still open"
            },
            "not_arrived": {
              "body": [
                {
                  "id": "rows",
                  "op": "core.let",
                  "title": "Who reported it missing, and when",
                  "params": {
                    "be": [
                      {
                        "at": "$now.iso",
                        "by": "$runner.name",
                        "id": "$order",
                        "note": "$note",
                        "state": "missing"
                      }
                    ]
                  },
                  "verify": [
                    "rows[0].id != ''"
                  ]
                },
                {
                  "op": "daslab_table_upsert",
                  "title": "Mark it not arrived",
                  "params": {
                    "key": "id",
                    "rows": "$rows",
                    "asset_id": "$cell_id"
                  }
                }
              ],
              "inputs": {
                "note": "",
                "order": ""
              },
              "description": "Not arrived"
            }
          }
        }
      },
      "layout": {
        "sortOrder": 5,
        "size": "medium"
      }
    }
  ]
}
