<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Board Overview</title>
<script src="https://preview.getaptly.com/api/ai/app-builder/sdk.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f8f7f4;
color: #1a1a18;
font-size: 14px;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
background: #fff;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.1);
}
.topbar h1 {
font-size: 15px;
font-weight: 500;
}
.timestamp {
font-size: 12px;
color: #888;
}
.content {
padding: 20px;
}
.error-banner {
background: #fcebeb;
color: #a32d2d;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
margin-bottom: 16px;
display: none;
white-space: pre-line;
}
.loading-state {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 60px 20px;
color: #888;
font-size: 13px;
}
.loading-state .spinner {
width: 22px;
height: 22px;
border: 2px solid rgba(0, 0, 0, 0.1);
border-top-color: #1a1a18;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.stage-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 20px;
}
.stage-box {
min-width: 130px;
flex: 0 1 auto;
background: #fff;
border: 0.5px solid rgba(0, 0, 0, 0.1);
border-radius: 10px;
padding: 14px 16px;
cursor: pointer;
text-align: left;
transition:
border-color 0.15s,
background 0.15s;
}
.stage-box:hover {
background: #f0efe9;
}
.stage-box.active {
border-color: #1a1a18;
border-width: 1.5px;
background: #f0efe9;
}
.stage-box .count {
font-size: 22px;
font-weight: 500;
}
.stage-box .label {
font-size: 11px;
color: #888;
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.card-table {
width: 100%;
font-size: 13px;
border-collapse: collapse;
background: #fff;
border-radius: 8px;
overflow: hidden;
border: 0.5px solid rgba(0, 0, 0, 0.08);
}
.card-table th {
text-align: left;
font-size: 11px;
font-weight: 500;
color: #888;
padding: 8px 12px;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.08);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.card-table td {
padding: 8px 12px;
border-bottom: 0.5px solid rgba(0, 0, 0, 0.06);
}
.card-table tr:last-child td {
border-bottom: none;
}
.card-table tr:hover td {
background: #f8f7f4;
}
.stage-pill {
display: inline-block;
font-size: 11px;
padding: 2px 8px;
border-radius: 6px;
font-weight: 500;
background: #e6f1fb;
color: #185fa5;
}
.card-row {
cursor: pointer;
}
.action-cell {
white-space: nowrap;
text-align: right;
}
.action-btn {
border: none;
background: none;
cursor: pointer;
font-size: 14px;
padding: 4px 6px;
border-radius: 6px;
color: #888;
line-height: 1;
}
.action-btn:hover {
background: #f0efe9;
color: #1a1a18;
}
.filter-note {
font-size: 12px;
color: #666;
margin-bottom: 10px;
}
</style>
</head>
<body>
<div class="topbar">
<h1>Board Overview</h1>
<span class="timestamp" id="timestamp"></span>
</div>
<div class="content">
<div class="error-banner" id="errorBanner"></div>
<div class="loading-state" id="loadingState">
<div class="spinner"></div>
<span id="loadingText">Connecting to Aptly...</span>
</div>
<div id="dashboard" style="display:none">
<div class="stage-grid" id="stageGrid"></div>
<div class="filter-note" id="filterNote"></div>
<table class="card-table">
<thead>
<tr>
<th>Card</th>
<th>Stage</th>
<th>Last updated</th>
<th></th>
</tr>
</thead>
<tbody id="cardRows"></tbody>
</table>
</div>
</div>
<script>
"use strict";
/* -- Configuration ----------------------------------------------- */
function boardId() {
return (
(window.aptly && aptly.config && aptly.config.BOARD_ID) ||
"YOUR_DEFAULT_BOARD"
);
}
/* -- Error surface: all errors render on screen, never console-only -- */
function showError(msg) {
var eb = document.getElementById("errorBanner");
eb.textContent = msg;
eb.style.display = "block";
document.getElementById("loadingState").style.display = "none";
}
window.addEventListener("error", function (ev) {
showError("Unexpected error: " + (ev.message || "unknown"));
});
window.addEventListener("unhandledrejection", function (ev) {
var r = ev.reason;
showError(
"Unexpected error: " + (r && r.message ? r.message : String(r))
);
});
function setLoading(on, text) {
document.getElementById("loadingState").style.display = on
? "flex"
: "none";
if (text) document.getElementById("loadingText").textContent = text;
if (!on) document.getElementById("dashboard").style.display = "";
}
/* -- SDK readiness, polled with a bounded attempt count --------------- */
function whenSdkReady(timeoutMs) {
return new Promise(function (resolve, reject) {
var maxAttempts = Math.ceil(timeoutMs / 100);
var attempts = 0;
(function check() {
if (window.aptly) return resolve(window.aptly);
attempts += 1;
if (attempts >= maxAttempts) {
return reject(
new Error(
"The Aptly SDK did not load after " +
attempts +
" attempts. " +
"This page must be hosted inside Aptly as a board tab; " +
"for local testing open it in a regular browser tab with dev emulation."
)
);
}
setTimeout(check, 100);
})();
});
}
function withTimeout(promise, ms, doing) {
return new Promise(function (resolve, reject) {
var timer = setTimeout(function () {
reject(
new Error("Timed out after " + ms / 1000 + "s while " + doing)
);
}, ms);
promise.then(
function (v) {
clearTimeout(timer);
resolve(v);
},
function (e) {
clearTimeout(timer);
reject(e);
}
);
});
}
function delay(ms) {
return new Promise(function (resolve) {
setTimeout(resolve, ms);
});
}
/* -- Schema-first field resolution: labels and keys, never hardcoded -- */
async function fetchSchema(bid) {
var res = await aptly.fetch("/schema/" + encodeURIComponent(bid));
if (!res.ok)
throw await httpError(
res,
'loading the schema for board "' + bid + '"',
bid
);
return res.json();
}
function resolveFields(schema, wanted) {
var byLabel = {},
byKey = {};
schema.forEach(function (f) {
if (!f) return;
if (f.label) byLabel[f.label.trim().toLowerCase()] = f;
if (f.key) byKey[String(f.key).trim().toLowerCase()] = f;
});
var map = {},
missing = [];
Object.keys(wanted).forEach(function (name) {
var match = null;
wanted[name].some(function (candidate) {
var c = candidate.toLowerCase();
match = byLabel[c] || byKey[c] || null;
return !!match;
});
if (match) map[name] = match.key;
else missing.push(wanted[name][0]);
});
if (missing.length) {
var available = schema
.map(function (f) {
return f && f.label;
})
.filter(Boolean)
.sort()
.join(", ");
throw new Error(
"This board is missing fields the dashboard needs: " +
missing.join(", ") +
". " +
"Fields found on this board: " +
(available || "(none)")
);
}
return map;
}
/* -- Pagination with rate limiting, 429 retry, and a page cap --------- */
async function fetchAllCards(bid, onProgress) {
var PAGE_SIZE = 1000;
var MAX_PAGES = 60;
var INTER_PAGE_MS = 1000;
var all = [],
total = null;
for (var page = 0; page < MAX_PAGES; page++) {
if (page > 0) await delay(INTER_PAGE_MS);
var url =
"/board/" +
encodeURIComponent(bid) +
"?page=" +
page +
"&pageSize=" +
PAGE_SIZE;
var res = await aptly.fetch(url);
if (res.status === 429) {
var retryAfter = parseInt(
res.headers && res.headers.get && res.headers.get("Retry-After"),
10
);
var waitMs = (isNaN(retryAfter) ? 5 : retryAfter) * 1000;
if (onProgress)
onProgress(
all.length,
total,
"Rate limited -- retrying in " +
Math.ceil(waitMs / 1000) +
"s..."
);
await delay(waitMs);
res = await aptly.fetch(url);
}
if (!res.ok)
throw await httpError(
res,
'loading cards from board "' + bid + '"',
bid
);
var body = await res.json();
var cards = body.data || [];
if (total == null) total = body.count != null ? body.count : null;
all = all.concat(cards);
if (onProgress) onProgress(all.length, total);
if (
(total != null && all.length >= total) ||
cards.length < PAGE_SIZE
)
break;
}
return all;
}
/* -- Translate API error codes into instructions users can act on ----- */
async function httpError(res, doing, bid) {
var code = "",
apiMsg = "";
try {
var b = await res.json();
code = (b.error && b.error.code) || "";
apiMsg = (b.error && b.error.message) || "";
} catch (e) {}
if (code === "API_DISABLED")
return new Error(
'API access is not enabled for board "' +
bid +
'". An admin can enable it in the board settings, then refresh.'
);
if (code === "BOARD_NOT_FOUND" || res.status === 404)
return new Error(
'Board "' +
bid +
'" was not found while ' +
doing +
". Set the BOARD_ID config variable to the correct board UUID."
);
if (res.status === 401)
return new Error(
"Your Aptly token expired or is invalid while " +
doing +
". Reload the page to get a fresh token."
);
if (res.status === 403)
return new Error(
"The delegate token does not have read access to this board. An admin needs to grant the boards scope for this app."
);
return new Error(
"Aptly API returned " +
res.status +
(apiMsg ? " (" + apiMsg + ")" : "") +
" while " +
doing +
"."
);
}
/* -- Aggregation: pure, testable --------------------------------- */
function countByStage(cards, stageKey) {
/* Returns [{stage, count}] sorted by count descending; cards with
no stage value group under "(no stage)". */
var counts = {};
cards.forEach(function (c) {
var s = c[stageKey];
var label =
s == null || String(s).trim() === "" ? "(no stage)" : String(s);
counts[label] = (counts[label] || 0) + 1;
});
return Object.keys(counts)
.map(function (k) {
return { stage: k, count: counts[k] };
})
.sort(function (a, b) {
return b.count - a.count || a.stage.localeCompare(b.stage);
});
}
function cardsForStage(cards, stageKey, stage) {
if (stage == null) return cards;
return cards.filter(function (c) {
var s = c[stageKey];
var label =
s == null || String(s).trim() === "" ? "(no stage)" : String(s);
return label === stage;
});
}
/* -- Embed actions: open card pane/view, composer, dialer ------------- */
/* Named SDK methods post to the Aptly parent window and resolve
{ success, error? }. They require admin-granted action permissions
per embed and are no-ops outside Aptly. Failures render on screen. */
function cardId(c) {
return c._id || c.id || c.uuid || null;
}
function actionFailureMessage(error, label) {
if (error === "not-in-iframe") {
return (
'UI actions like "' +
label +
'" only work when this page is embedded in Aptly.'
);
}
if (error === "timeout") {
return (
'Aptly did not respond to "' +
label +
'". The action may not be enabled ' +
"for this embed -- an admin can grant it in the embed settings."
);
}
return (
'The "' + label + '" action failed: ' + (error || "unknown error")
);
}
function runAction(promise, label) {
promise
.then(function (result) {
if (!result || !result.success) {
showError(actionFailureMessage(result && result.error, label));
}
})
.catch(function (e) {
showError(
'The "' +
label +
'" action failed: ' +
(e && e.message ? e.message : String(e))
);
});
}
function openPane(c) {
var id = cardId(c);
if (!id) {
showError("This card has no id, so it cannot be opened.");
return;
}
runAction(aptly.openCardPane({ cardId: id }), "open card");
}
function openPopout(c) {
var id = cardId(c);
if (!id) {
showError("This card has no id, so it cannot be opened.");
return;
}
runAction(aptly.openCardView({ cardId: id }), "expand card");
}
async function fetchCardContacts(c) {
/* Linked person contacts for a card; used to resolve email recipients
and phone numbers, which the card record does not carry directly. */
var id = cardId(c);
if (!id)
throw new Error(
"This card has no id, so its contacts cannot be looked up."
);
var res = await aptly.fetch(
"/board/" +
encodeURIComponent(boardId()) +
"/" +
encodeURIComponent(id) +
"/contacts"
);
if (!res.ok)
throw await httpError(
res,
"loading contacts for this card",
boardId()
);
var body = await res.json();
return Array.isArray(body) ? body : body.data || [];
}
/* Contact phone numbers live at contact.phone: an array of
{ number: '+16617558531', type: 'mobile' } objects, usually with
multiple entries. Normalize entries to the bare number string the
dialer expects; email entries are normalized the same way. */
function phoneNumberOf(entry) {
if (!entry) return null;
if (typeof entry === "string") return entry;
if (typeof entry === "object" && typeof entry.number === "string")
return entry.number;
return null;
}
function emailAddressOf(entry) {
if (!entry) return null;
if (typeof entry === "string") return entry;
if (typeof entry === "object") {
if (typeof entry.address === "string") return entry.address;
if (typeof entry.email === "string") return entry.email;
}
return null;
}
function contactPhone(p) {
if (Array.isArray(p.phone)) {
for (var i = 0; i < p.phone.length; i++) {
var n = phoneNumberOf(p.phone[i]);
if (n) return n;
}
return null;
}
return phoneNumberOf(p.phone);
}
function contactEmail(p) {
if (Array.isArray(p.email)) {
for (var i = 0; i < p.email.length; i++) {
var e = emailAddressOf(p.email[i]);
if (e) return e;
}
return null;
}
return emailAddressOf(p.email);
}
async function openComposer(c) {
/* The composer accepts recipients as a `to` list of email addresses.
Resolve them from the card's linked contacts; open with just the
subject when no linked contact has an email. */
try {
var contacts = await fetchCardContacts(c);
var to = [];
contacts.forEach(function (p) {
var email = contactEmail(p);
if (email) to.push(email);
});
var payload = { subject: c.name || "" };
if (to.length) payload.to = to;
runAction(aptly.openEmailComposer(payload), "compose email");
} catch (e) {
showError(e && e.message ? e.message : String(e));
}
}
async function dialCard(c) {
/* The dialer needs an E.164 number string; resolve it from linked contacts. */
try {
var contacts = await fetchCardContacts(c);
var found = null;
contacts.some(function (p) {
var number = contactPhone(p);
if (number) {
found = { number: number, name: p.name || "" };
return true;
}
return false;
});
if (!found) {
showError(
'No linked contact with a phone number was found on "' +
(c.name || "this card") +
'".'
);
return;
}
runAction(aptly.startDialer(found), "start call");
} catch (e) {
showError(e && e.message ? e.message : String(e));
}
}
/* -- Rendering: createElement and textContent only, no HTML strings --- */
var STATE = { cards: [], stageKey: "stage", activeStage: null };
function fmtDate(iso) {
if (!iso) return "-";
var d = new Date(iso);
if (isNaN(d)) return "-";
return d.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric"
});
}
function renderStageBoxes() {
var grid = document.getElementById("stageGrid");
grid.textContent = "";
function makeBox(label, count, stageValue) {
var box = document.createElement("button");
box.className =
"stage-box" + (STATE.activeStage === stageValue ? " active" : "");
var countEl = document.createElement("div");
countEl.className = "count";
countEl.textContent = count;
var labelEl = document.createElement("div");
labelEl.className = "label";
labelEl.textContent = label;
box.appendChild(countEl);
box.appendChild(labelEl);
box.addEventListener("click", function () {
/* Click toggles: same stage again (or All) clears the filter. */
STATE.activeStage =
STATE.activeStage === stageValue ? null : stageValue;
renderStageBoxes();
renderTable();
});
grid.appendChild(box);
}
makeBox("All cards", STATE.cards.length, null);
countByStage(STATE.cards, STATE.stageKey).forEach(function (entry) {
makeBox(entry.stage, entry.count, entry.stage);
});
}
function renderTable() {
var tbody = document.getElementById("cardRows");
tbody.textContent = "";
var note = document.getElementById("filterNote");
note.textContent =
STATE.activeStage == null
? "Showing all " + STATE.cards.length + " cards"
: "Showing cards in stage: " +
STATE.activeStage +
" (click the box again to clear)";
var rows = cardsForStage(
STATE.cards,
STATE.stageKey,
STATE.activeStage
);
if (rows.length === 0) {
var tr = document.createElement("tr");
var td = document.createElement("td");
td.colSpan = 4;
td.textContent =
STATE.activeStage == null
? "This board has no cards."
: "No cards in this stage.";
td.style.color = "#888";
td.style.textAlign = "center";
td.style.padding = "16px";
tr.appendChild(td);
tbody.appendChild(tr);
return;
}
rows.forEach(function (c) {
var tr = document.createElement("tr");
tr.className = "card-row";
/* Clicking the row opens the card in the board side pane. */
tr.addEventListener("click", function () {
openPane(c);
});
var tdName = document.createElement("td");
tdName.textContent = c.name || "(unnamed card)";
tdName.style.fontWeight = "500";
tr.appendChild(tdName);
var tdStage = document.createElement("td");
var pill = document.createElement("span");
pill.className = "stage-pill";
var s = c[STATE.stageKey];
pill.textContent =
s == null || String(s).trim() === "" ? "(no stage)" : String(s);
tdStage.appendChild(pill);
tr.appendChild(tdStage);
var tdUpd = document.createElement("td");
tdUpd.textContent = fmtDate(c.updatedAt);
tdUpd.style.color = "#888";
tr.appendChild(tdUpd);
/* Action icons. stopPropagation keeps icon clicks from also
triggering the row's open-pane handler. Glyphs are set via
unicode escapes to keep the source ASCII. */
var tdActions = document.createElement("td");
tdActions.className = "action-cell";
function addIcon(glyph, title, handler) {
var btn = document.createElement("button");
btn.className = "action-btn";
btn.type = "button";
btn.title = title;
btn.textContent = glyph;
btn.addEventListener("click", function (ev) {
ev.stopPropagation();
handler(c);
});
tdActions.appendChild(btn);
}
addIcon("\u2709", "Compose email", openComposer);
addIcon("\u260E", "Call linked contact", dialCard);
addIcon("\u2197", "Expand card", openPopout);
tr.appendChild(tdActions);
tbody.appendChild(tr);
});
}
/* -- Load + boot ------------------------------------------------- */
async function loadData() {
try {
setLoading(true, "Reading board schema...");
var schema = await fetchSchema(boardId());
var F = resolveFields(schema, { stage: ["Stage"] });
STATE.stageKey = F.stage;
var cards = await fetchAllCards(boardId(), function (n, total, msg) {
document.getElementById("loadingText").textContent =
msg || "Loading cards... " + n + (total ? " of " + total : "");
});
STATE.cards = cards;
STATE.activeStage = null;
document.getElementById("timestamp").textContent =
"As of " +
new Date().toLocaleDateString(undefined, {
year: "numeric",
month: "long",
day: "numeric"
});
setLoading(false);
renderStageBoxes();
renderTable();
} catch (e) {
setLoading(false);
document.getElementById("dashboard").style.display = "none";
showError(e && e.message ? e.message : String(e));
}
}
var booted = false;
function init() {
if (booted) return;
booted = true;
loadData();
}
window.init = init;
(function boot() {
whenSdkReady(10000)
.then(function (sdk) {
if (sdk.token) {
init();
return;
}
/* requestToken({version: 2}) resolves { token, error } -- it does
NOT reject on denial. Fail fast on errors; retry
once on RATE_LIMITED. */
if (typeof sdk.requestToken === "function") {
var requestOnce = function () {
return withTimeout(
sdk.requestToken({ version: 2 }),
8000,
"requesting the Aptly delegate token"
);
};
return requestOnce()
.then(function (result) {
var isV2 =
result && typeof result === "object" && "error" in result;
var error = isV2 ? result.error : null;
var token = isV2 ? result.token : result;
if (
error &&
error.code === "RATE_LIMITED" &&
error.retryAfterMs
) {
setLoading(
true,
"Token request rate limited -- retrying in " +
Math.ceil(error.retryAfterMs / 1000) +
"s..."
);
return delay(error.retryAfterMs)
.then(requestOnce)
.then(function (r2) {
if (r2 && r2.error)
throw new Error(
r2.error.message + " (" + r2.error.code + ")"
);
if (!(r2 && r2.token))
throw new Error("No token after rate-limit retry.");
init();
});
}
if (error)
throw new Error(error.message + " (" + error.code + ")");
if (!token)
throw new Error(
'Aptly returned no token. Ask an admin to enable "Needs Aptly Context" ' +
"in the board view settings for this tab and confirm this app is authorized."
);
init();
})
.catch(function (e) {
showError(
"Aptly token request failed: " +
(e && e.message ? e.message : String(e))
);
});
}
/* Fallback for SDK builds without requestToken: bounded poll. */
var MAX_CTX = 40;
var attempts = 0;
var poll = setInterval(function () {
if (window.aptly && aptly.token) {
clearInterval(poll);
init();
return;
}
attempts += 1;
if (attempts >= MAX_CTX) {
clearInterval(poll);
if (!booted)
showError(
"No Aptly context received after " +
MAX_CTX +
" attempts. " +
'Ask an admin to enable "Needs Aptly Context" in the board view settings for this tab.'
);
}
}, 200);
})
.catch(function (e) {
showError(e.message);
});
})();
</script>
</body>
</html>