slotlock 0.3.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +265 -0
- package/README.md +226 -17
- package/agent-server.d.ts +51 -8
- package/agent-server.js +478 -36
- package/agent-store-backend.d.ts +6 -0
- package/agent-store-backend.js +771 -30
- package/application-api.d.ts +22 -1
- package/application-api.js +412 -3
- package/booking-rules.d.ts +84 -0
- package/booking-rules.js +149 -0
- package/config.d.ts +20 -1
- package/config.js +33 -0
- package/dashboard.d.ts +61 -5
- package/dashboard.js +995 -139
- package/ddl.d.ts +5 -5
- package/ddl.js +591 -5
- package/engine.d.ts +5 -0
- package/engine.js +7 -4
- package/event-feed.d.ts +178 -0
- package/event-feed.js +447 -0
- package/index.d.ts +8 -2
- package/index.js +6 -1
- package/interop.d.ts +117 -0
- package/interop.js +356 -0
- package/maintenance.d.ts +14 -2
- package/maintenance.js +35 -3
- package/mcp-app.d.ts +7 -1
- package/mcp-app.js +259 -40
- package/metrics.d.ts +24 -0
- package/metrics.js +167 -0
- package/openapi.json +8346 -3400
- package/package.json +1 -1
- package/rate-limit.d.ts +10 -1
- package/rate-limit.js +25 -2
- package/rest-api.d.ts +22 -0
- package/rest-api.js +160 -15
- package/self-host.d.ts +26 -3
- package/self-host.js +316 -19
- package/slotlock-manifest.schema.json +1 -0
- package/slotlock.manifest.json +2 -1
- package/store.d.ts +317 -8
- package/store.js +1005 -89
- package/types.d.ts +87 -0
- package/types.js +0 -2
- package/webhooks.d.ts +91 -0
- package/webhooks.js +300 -0
package/mcp-app.d.ts
CHANGED
|
@@ -9,8 +9,14 @@ export declare function clientSupportsSlotlockMcpApp(capabilities: unknown): boo
|
|
|
9
9
|
* `ui/notifications/tool-result` channel; the template contains no tenant or event data and uses
|
|
10
10
|
* textContent for every untrusted value. Keeping this as reviewed source instead of generated UI
|
|
11
11
|
* also lets MCP hosts inspect and cache the exact resource before rendering it.
|
|
12
|
+
*
|
|
13
|
+
* A certain slot (from `slotlock_find_available_slots` or `slotlock_find_next_available`) can be
|
|
14
|
+
* picked: the view sends the host one `ui/message` naming it, in a fixed sentence holding only the
|
|
15
|
+
* resource id and the two instants it checked, so the agent books it through the normal path,
|
|
16
|
+
* where Slotlock asks a person to confirm the write if it is set to. The view itself never calls a
|
|
17
|
+
* tool, and every write tool is visible to the model only.
|
|
12
18
|
*/
|
|
13
|
-
export declare const SLOTLOCK_MCP_APP_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>Slotlock calendar</title>\n <style>\n :root{color-scheme:light dark;font-family:var(--font-sans,ui-sans-serif,system-ui,sans-serif);background:var(--color-background-primary,#f5f5f0);color:var(--color-text-primary,#171915)}\n *{box-sizing:border-box}body{margin:0;min-width:0;background:transparent}button,input{font:inherit}\n .shell{display:grid;gap:14px;padding:16px;max-width:1000px;margin:0 auto}\n .mast{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:2px 2px 10px;border-bottom:1px solid var(--color-border-primary,#d7d9d2)}\n .eyebrow{margin:0 0 5px;font-size:11px;line-height:1.2;letter-spacing:.12em;text-transform:uppercase;color:var(--color-text-secondary,#667064)}\n h1{margin:0;font-size:clamp(22px,4vw,32px);line-height:1;letter-spacing:-.045em}p{margin:0}.status{display:inline-flex;align-items:center;gap:7px;min-height:30px;padding:6px 10px;border:1px solid var(--color-border-primary,#d7d9d2);border-radius:999px;font-size:12px;color:var(--color-text-secondary,#667064);background:var(--color-background-secondary,#fff)}\n .dot{width:7px;height:7px;border-radius:50%;background:#b7791f}.status[data-ready=\"true\"] .dot{background:#23855b}\n .toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.search{min-width:min(100%,280px);min-height:42px;padding:9px 12px;border:1px solid var(--color-border-primary,#c9cdc4);border-radius:9px;background:var(--color-background-secondary,#fff);color:inherit}.search:focus-visible{outline:3px solid color-mix(in srgb,var(--color-ring-primary,#2e5bff) 42%,transparent);outline-offset:2px}\n .summary{color:var(--color-text-secondary,#667064);font-size:13px}.panel{min-width:0;border:1px solid var(--color-border-primary,#d7d9d2);border-radius:12px;background:var(--color-background-secondary,#fff);overflow:hidden}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 14px;border-bottom:1px solid var(--color-border-primary,#e1e3dd)}h2{margin:0;font-size:14px;letter-spacing:-.01em}.items{display:grid;min-width:0}.item{display:grid;grid-template-columns:minmax(130px,.85fr) minmax(0,1.5fr) auto;gap:12px;align-items:start;padding:13px 14px;border-bottom:1px solid var(--color-border-primary,#eceee8)}.item:last-child{border-bottom:0}.item-title{font-weight:650;overflow-wrap:anywhere}.item-detail{min-width:0;color:var(--color-text-secondary,#596157);font-size:13px;line-height:1.45;overflow-wrap:anywhere}.badge{justify-self:end;padding:4px 7px;border:1px solid var(--color-border-primary,#d7d9d2);border-radius:6px;font-size:11px;color:var(--color-text-secondary,#596157);white-space:nowrap}.empty{padding:30px 16px;text-align:center;color:var(--color-text-secondary,#667064);font-size:14px}.error{color:var(--color-text-danger,#9b2c2c)}\n details{border-top:1px solid var(--color-border-primary,#e1e3dd)}summary{cursor:pointer;padding:11px 14px;color:var(--color-text-secondary,#667064);font-size:12px}pre{margin:0;padding:14px;max-height:260px;overflow:auto;font:12px/1.5 var(--font-mono,ui-monospace,monospace);white-space:pre-wrap;overflow-wrap:anywhere;background:var(--color-background-tertiary,#f6f7f3)}\n @media(max-width:600px){.shell{padding:12px}.mast{align-items:stretch;flex-direction:column}.status{align-self:flex-start}.item{grid-template-columns:1fr}.badge{justify-self:start}.search{width:100%;font-size:16px}}\n @media(prefers-reduced-motion:no-preference){.dot{transition:background-color .18s ease}.item{animation:reveal .16s ease-out both}@keyframes reveal{from{opacity:.5;transform:translateY(3px)}to{opacity:1;transform:none}}}\n </style>\n</head>\n<body>\n <main class=\"shell\">\n <header class=\"mast\">\n <div><p class=\"eyebrow\">Resource-first scheduling</p><h1>Slotlock calendar</h1></div>\n <div class=\"status\" id=\"status\" data-ready=\"false\" role=\"status\"><span class=\"dot\" aria-hidden=\"true\"></span><span id=\"statusText\">Connecting to host</span></div>\n </header>\n <div class=\"toolbar\">\n <label><span class=\"eyebrow\">Filter this result</span><input id=\"filter\" class=\"search\" type=\"search\" autocomplete=\"off\" placeholder=\"Resource, event or time\"></label>\n <p class=\"summary\" id=\"summary\" aria-live=\"polite\">Waiting for a calendar result.</p>\n </div>\n <section class=\"panel\" aria-labelledby=\"resultHeading\">\n <div class=\"panel-head\"><h2 id=\"resultHeading\">Calendar result</h2></div>\n <div class=\"items\" id=\"items\"><p class=\"empty\">Ask your agent to list resources, inspect free-busy, find a slot, or list events.</p></div>\n <details><summary>Structured result</summary><pre id=\"raw\">No result yet.</pre></details>\n </section>\n </main>\n <script>\n (() => {\n 'use strict';\n const state = { data: null, query: '', requestId: 1 };\n const status = document.getElementById('status');\n const statusText = document.getElementById('statusText');\n const items = document.getElementById('items');\n const summary = document.getElementById('summary');\n const raw = document.getElementById('raw');\n const filter = document.getElementById('filter');\n const safeText = (value) => value === null || value === undefined ? '\u2014' : String(value);\n const post = (message) => window.parent.postMessage(message, '*');\n const setStatus = (text, ready) => { statusText.textContent = text; status.dataset.ready = ready ? 'true' : 'false'; };\n const clear = (node) => { while (node.firstChild) node.removeChild(node.firstChild); };\n const line = (title, detail, badge) => {\n const row = document.createElement('article'); row.className = 'item';\n const heading = document.createElement('div'); heading.className = 'item-title'; heading.textContent = safeText(title);\n const body = document.createElement('div'); body.className = 'item-detail'; body.textContent = safeText(detail);\n const mark = document.createElement('div'); mark.className = 'badge'; mark.textContent = safeText(badge);\n row.append(heading, body, mark); return row;\n };\n const intervalText = (entry) => [entry.start || entry.starts_at, entry.end || entry.ends_at].filter(Boolean).join(' \u2192 ');\n // A refused call carries no structuredContent (hosts validate it against the tool's\n // outputSchema); its text content is {\"error\":{\"code\":\u2026}}.\n const refusalOf = (content) => {\n const text = Array.isArray(content) ? content.find((part) => part && part.type === 'text')?.text : undefined;\n try {\n const parsed = JSON.parse(text);\n if (parsed && parsed.error && typeof parsed.error === 'object') return { error: parsed.error };\n } catch {}\n return { error: { code: 'request_refused' } };\n };\n const flatten = (data) => {\n if (!data || typeof data !== 'object') return [];\n if (data.error) return [{ title: data.error.code || 'Unable to load', detail: 'The calendar server refused this request.', badge: 'Error' }];\n if (Array.isArray(data.resources)) return data.resources.flatMap((resource) => {\n if (Array.isArray(resource.busy)) return resource.busy.map((busy) => ({ title: resource.resource_id, detail: intervalText(busy), badge: resource.coverage?.certainty || 'busy' }));\n return [{ title: resource.external_ref || resource.id || resource.resource_id, detail: resource.timezone || 'Calendar resource', badge: resource.coverage?.certainty || 'resource' }];\n });\n if (Array.isArray(data.events)) return data.events.map((event) => ({ title: event.title || event.id, detail: intervalText(event), badge: event.status || event.transparency || 'event' }));\n if (data.event && typeof data.event === 'object') return [{ title: data.event.title || data.event.id, detail: intervalText(data.event), badge: data.event.status || 'event' }];\n if ('start' in data || 'end' in data) {\n const coverage = data.coverage && typeof data.coverage === 'object' ? data.coverage : null;\n const certainty = coverage?.certainty;\n const completeInterval = typeof data.start === 'string' && typeof data.end === 'string';\n if (certainty !== 'certain' || !completeInterval) {\n const reason = typeof coverage?.reason === 'string' ? coverage.reason.replaceAll('_', ' ') : 'Required calendar coverage is incomplete.';\n return [{ title: 'Availability unproven', detail: reason, badge: certainty || 'uncertain' }];\n }\n return [{ title: data.resource_id || 'Next available', detail: intervalText(data), badge: certainty }];\n }\n return Object.entries(data).slice(0, 100).map(([key, value]) => ({ title: key, detail: typeof value === 'object' ? JSON.stringify(value) : safeText(value), badge: 'value' }));\n };\n const render = () => {\n clear(items);\n const rows = flatten(state.data).filter((entry) => (entry.title + ' ' + entry.detail + ' ' + entry.badge).toLowerCase().includes(state.query));\n if (rows.length === 0) { const empty = document.createElement('p'); empty.className = 'empty'; empty.textContent = state.data ? 'No rows match this filter.' : 'Waiting for a calendar result.'; items.append(empty); }\n else rows.forEach((entry) => items.append(line(entry.title, entry.detail, entry.badge)));\n summary.textContent = rows.length === 1 ? '1 calendar row' : rows.length + ' calendar rows';\n raw.textContent = state.data ? JSON.stringify(state.data, null, 2) : 'No result yet.';\n };\n filter.addEventListener('input', () => { state.query = filter.value.trim().toLowerCase(); render(); });\n window.addEventListener('message', (event) => {\n if (event.source !== window.parent) return;\n const message = event.data;\n if (!message || message.jsonrpc !== '2.0') return;\n if (message.id === state.requestId && message.result) {\n post({ jsonrpc: '2.0', method: 'ui/notifications/initialized' });\n setStatus('Connected', true);\n return;\n }\n if (message.method === 'ui/notifications/tool-result') {\n const result = message.params || {};\n state.data = result.structuredContent || (result.isError ? refusalOf(result.content) : null);\n setStatus(result.isError ? 'Request refused' : 'Result current', !result.isError);\n render();\n }\n if (message.method === 'ui/notifications/host-context-changed' && message.params?.theme) document.documentElement.dataset.theme = message.params.theme;\n });\n post({ jsonrpc: '2.0', id: state.requestId, method: 'ui/initialize', params: { appInfo: { name: 'slotlock-calendar', version: '1.0.0' }, appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, protocolVersion: '2026-01-26' } });\n })();\n </script>\n</body>\n</html>";
|
|
19
|
+
export declare const SLOTLOCK_MCP_APP_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>Slotlock calendar</title>\n <style>\n :root{color-scheme:light dark;--fb-bg:#fbfbf9;--fb-surface:#ffffff;--fb-sunken:#f3f4f0;--fb-text:#171915;--fb-muted:#5d655b;--fb-faint:#8a9187;--fb-line:#e3e5df;--fb-line-strong:#c9cdc4;--fb-inverse:#171915;--fb-on-inverse:#ffffff;--fb-ring:#2e5bff;--fb-ok:#23855b;--fb-warn:#b7791f;--fb-danger:#9b2c2c}\n @media (prefers-color-scheme:dark){:root:not([data-theme=\"light\"]){--fb-bg:#121411;--fb-surface:#1a1d19;--fb-sunken:#22261f;--fb-text:#eef0ea;--fb-muted:#a9b0a5;--fb-faint:#7d847a;--fb-line:#2c302a;--fb-line-strong:#3d4339;--fb-inverse:#eef0ea;--fb-on-inverse:#121411;--fb-ring:#7d9bff;--fb-ok:#5cc495;--fb-warn:#e0a44a;--fb-danger:#f08a8a}}\n :root[data-theme=\"dark\"]{--fb-bg:#121411;--fb-surface:#1a1d19;--fb-sunken:#22261f;--fb-text:#eef0ea;--fb-muted:#a9b0a5;--fb-faint:#7d847a;--fb-line:#2c302a;--fb-line-strong:#3d4339;--fb-inverse:#eef0ea;--fb-on-inverse:#121411;--fb-ring:#7d9bff;--fb-ok:#5cc495;--fb-warn:#e0a44a;--fb-danger:#f08a8a}\n :root{--bg:var(--color-background-primary,var(--fb-bg));--surface:var(--color-background-secondary,var(--fb-surface));--sunken:var(--color-background-tertiary,var(--fb-sunken));--text:var(--color-text-primary,var(--fb-text));--muted:var(--color-text-secondary,var(--fb-muted));--faint:var(--color-text-tertiary,var(--fb-faint));--line:var(--color-border-primary,var(--fb-line));--line-strong:var(--color-border-secondary,var(--fb-line-strong));--inverse:var(--color-background-inverse,var(--fb-inverse));--on-inverse:var(--color-text-inverse,var(--fb-on-inverse));--ring:var(--color-ring-primary,var(--fb-ring));--ok:var(--color-text-success,var(--fb-ok));--danger:var(--color-text-danger,var(--fb-danger));font-family:var(--font-sans,ui-sans-serif,system-ui,-apple-system,\"Segoe UI\",sans-serif);color:var(--text);background:var(--bg)}\n *{box-sizing:border-box}body{margin:0;min-width:0;background:transparent;-webkit-font-smoothing:antialiased}button,input,textarea{font:inherit;color:inherit}p{margin:0}\n button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--ring);outline-offset:2px}\n .shell{display:grid;gap:12px;padding:16px;max-width:760px;margin:0 auto}\n .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}\n .status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}.dot{width:7px;height:7px;border-radius:50%;background:var(--fb-warn)}.status[data-ready=\"true\"] .dot{background:var(--ok)}\n /* The picker */\n .picker{display:grid;border:1px solid var(--line);border-radius:16px;background:var(--surface);overflow:hidden}.picker[hidden]{display:none}\n .head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:16px 16px 12px}\n .head h1{margin:0;font-size:var(--font-heading-sm-size,18px);font-weight:var(--font-weight-semibold,600);letter-spacing:-.01em}\n .meta{margin-top:4px;font-size:13px;color:var(--muted);display:flex;flex-wrap:wrap;gap:4px 10px}.meta span+span::before{content:\"\u00B7\";margin-right:10px;color:var(--faint)}\n .clock{display:inline-flex;padding:2px;border-radius:8px;background:var(--sunken);flex:none}.clock button{min-width:40px;padding:4px 8px;border:0;border-radius:6px;background:transparent;font-size:12px;color:var(--muted);cursor:pointer}.clock button[aria-pressed=\"true\"]{background:var(--surface);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.08)}\n .days{display:flex;gap:6px;padding:0 16px 12px;overflow-x:auto;scrollbar-width:none;scroll-snap-type:x proximity}.days::-webkit-scrollbar{display:none}.days[hidden]{display:none}\n .day{flex:none;display:grid;justify-items:center;gap:1px;width:56px;padding:8px 0 7px;border:1px solid var(--line);border-radius:12px;background:transparent;cursor:pointer;scroll-snap-align:start;font-variant-numeric:tabular-nums}\n .day small{font-size:11px;color:var(--muted)}.day strong{font-size:17px;font-weight:var(--font-weight-semibold,600);line-height:1.2}.day i{font-style:normal;font-size:10px;color:var(--faint)}\n .day:hover{border-color:var(--line-strong)}.day[aria-pressed=\"true\"]{background:var(--inverse);border-color:var(--inverse);color:var(--on-inverse)}.day[aria-pressed=\"true\"] small,.day[aria-pressed=\"true\"] i{color:inherit;opacity:.75}\n .times{display:grid;gap:14px;padding:14px 16px 16px;border-top:1px solid var(--line)}.times[hidden],.choice[hidden]{display:none}.foot:has(.choice[hidden]){justify-content:flex-end}\n .part h2{margin:0 0 8px;font-size:11px;font-weight:var(--font-weight-medium,500);letter-spacing:.06em;text-transform:uppercase;color:var(--faint)}\n .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}\n .time{display:grid;justify-items:center;gap:1px;min-height:42px;padding:8px 6px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface);cursor:pointer;font-variant-numeric:tabular-nums;font-weight:var(--font-weight-medium,500);transition:border-color .12s,background-color .12s}\n .time small{font-size:10px;font-weight:400;color:var(--muted)}.time:hover:not(:disabled){border-color:var(--text)}\n .time[aria-pressed=\"true\"]{background:var(--inverse);border-color:var(--inverse);color:var(--on-inverse)}.time[aria-pressed=\"true\"] small{color:inherit;opacity:.75}.time:disabled{cursor:default}.time:disabled:not([aria-pressed=\"true\"]){opacity:.45}\n .one{display:grid;gap:4px;padding:4px 16px 16px}.one strong{font-size:var(--font-heading-md-size,22px);font-weight:var(--font-weight-semibold,600);letter-spacing:-.01em;font-variant-numeric:tabular-nums}.one span{font-size:14px;color:var(--muted)}.one[hidden]{display:none}\n .foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;border-top:1px solid var(--line);background:var(--sunken)}\n .choice{min-width:0;font-size:13px;color:var(--muted);overflow-wrap:anywhere}.choice b{display:block;color:var(--text);font-weight:var(--font-weight-semibold,600)}\n .book{flex:none;min-height:40px;padding:9px 16px;border:0;border-radius:10px;background:var(--inverse);color:var(--on-inverse);font-weight:var(--font-weight-semibold,600);cursor:pointer}.book:disabled{cursor:default;opacity:.4}.book[data-sent=\"true\"]{opacity:1;background:transparent;color:var(--ok);padding-inline:4px}\n .fallback{display:grid;gap:6px;padding:12px 16px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}.fallback[hidden]{display:none}.fallback textarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line-strong);border-radius:8px;background:var(--bg);font:12px/1.5 var(--font-mono,ui-monospace,monospace);resize:vertical}\n /* Every other result */\n .list[hidden]{display:none}.list{display:grid;gap:12px}\n .mast{display:flex;align-items:center;justify-content:space-between;gap:12px}.mast h1{margin:0;font-size:var(--font-heading-sm-size,18px);font-weight:var(--font-weight-semibold,600)}\n .toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.search{flex:1;min-width:min(100%,220px);min-height:38px;padding:8px 12px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface)}.summary{font-size:13px;color:var(--muted)}\n .panel{min-width:0;border:1px solid var(--line);border-radius:14px;background:var(--surface);overflow:hidden}.items{display:grid;min-width:0}\n .item{display:grid;grid-template-columns:minmax(120px,.85fr) minmax(0,1.5fr) auto;gap:12px;align-items:start;padding:12px 14px;border-bottom:1px solid var(--line)}.item:last-child{border-bottom:0}.item-title{font-weight:var(--font-weight-semibold,600);overflow-wrap:anywhere}.item-detail{min-width:0;color:var(--muted);font-size:13px;line-height:1.45;overflow-wrap:anywhere}.badge{justify-self:end;padding:3px 7px;border:1px solid var(--line);border-radius:6px;font-size:11px;color:var(--muted);white-space:nowrap}.empty{padding:28px 16px;text-align:center;color:var(--muted);font-size:14px}\n details{border-top:1px solid var(--line)}summary{cursor:pointer;padding:10px 14px;color:var(--muted);font-size:12px}pre{margin:0;padding:14px;max-height:260px;overflow:auto;font:12px/1.5 var(--font-mono,ui-monospace,monospace);white-space:pre-wrap;overflow-wrap:anywhere;background:var(--sunken)}\n @media(max-width:520px){.shell{padding:10px}.item{grid-template-columns:1fr}.badge{justify-self:start}.grid{grid-template-columns:repeat(auto-fill,minmax(84px,1fr))}.foot{flex-wrap:wrap}.book{flex:1}}\n @media(prefers-reduced-motion:reduce){.time{transition:none}}\n </style>\n</head>\n<body>\n <main class=\"shell\">\n <section class=\"picker\" id=\"picker\" aria-labelledby=\"pickTitle\" hidden>\n <div class=\"head\">\n <div><h1 id=\"pickTitle\">Pick a time</h1><p class=\"meta\" id=\"pickMeta\"></p></div>\n <div class=\"clock\" role=\"group\" aria-label=\"Clock\"><button type=\"button\" id=\"h12\" aria-pressed=\"false\">12h</button><button type=\"button\" id=\"h24\" aria-pressed=\"false\">24h</button></div>\n </div>\n <div class=\"days\" id=\"days\" role=\"group\" aria-label=\"Day\"></div>\n <div class=\"one\" id=\"one\" hidden></div>\n <div class=\"times\" id=\"times\"></div>\n <div class=\"foot\">\n <p class=\"choice\" id=\"choice\" aria-live=\"polite\">No time picked</p>\n <button type=\"button\" class=\"book\" id=\"book\" disabled>Book this time</button>\n </div>\n <div class=\"fallback\" id=\"fallback\" hidden><p>Your host did not pass the choice on. Send this to your agent:</p><textarea id=\"fallbackText\" readonly></textarea></div>\n </section>\n <section class=\"list\" id=\"list\">\n <header class=\"mast\"><h1>Slotlock calendar</h1><p class=\"status\" id=\"status\" data-ready=\"false\" role=\"status\"><span class=\"dot\" aria-hidden=\"true\"></span><span id=\"statusText\">Connecting to host</span></p></header>\n <div class=\"toolbar\">\n <label class=\"sr\" for=\"filter\">Filter this result</label><input id=\"filter\" class=\"search\" type=\"search\" autocomplete=\"off\" placeholder=\"Filter by resource, event or time\">\n <p class=\"summary\" id=\"summary\" aria-live=\"polite\">Waiting for a calendar result.</p>\n </div>\n <div class=\"panel\">\n <div class=\"items\" id=\"items\"><p class=\"empty\">Ask your agent to list resources, inspect free-busy, find a slot, or list events.</p></div>\n <details><summary>Structured result</summary><pre id=\"raw\">No result yet.</pre></details>\n </div>\n </section>\n </main>\n <script>\n (() => {\n 'use strict';\n const state = { data: null, query: '', requestId: 1, nextId: 2, timeZone: undefined, locale: undefined, hour12: undefined, day: null, chosen: null, sent: false, pending: {}, size: '' };\n const $ = (id) => document.getElementById(id);\n const status = $('status'), statusText = $('statusText'), items = $('items'), summary = $('summary'), raw = $('raw'), filter = $('filter');\n const picker = $('picker'), list = $('list'), days = $('days'), times = $('times'), one = $('one'), choice = $('choice'), book = $('book'), pickMeta = $('pickMeta'), fallback = $('fallback'), fallbackText = $('fallbackText'), h12 = $('h12'), h24 = $('h24');\n const safeText = (value) => value === null || value === undefined ? '\u2014' : String(value);\n const post = (message) => window.parent.postMessage(message, '*');\n const setStatus = (text, ready) => { statusText.textContent = text; status.dataset.ready = ready ? 'true' : 'false'; };\n const clear = (node) => { while (node.firstChild) node.removeChild(node.firstChild); };\n const el = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; };\n\n // The host's look: its standard theme variables (only these names, as plain values) and its theme.\n const THEME_KEYS = /^--(color-(background|text|border|ring)-[a-z]+|font-(sans|mono)|font-weight-[a-z]+|font-(text|heading)-[a-z]+-size)$/;\n const applyHost = (context) => {\n if (!context || typeof context !== 'object') return;\n if (context.theme === 'light' || context.theme === 'dark') document.documentElement.dataset.theme = context.theme;\n const variables = context.styles && context.styles.variables;\n if (variables && typeof variables === 'object') {\n Object.entries(variables).forEach(([key, value]) => {\n if (THEME_KEYS.test(key) && typeof value === 'string' && value.length <= 200 && !/url\\(|expression|[;{}<>]/i.test(value)) document.documentElement.style.setProperty(key, value);\n });\n }\n if (typeof context.timeZone === 'string') state.timeZone = context.timeZone;\n if (typeof context.locale === 'string') state.locale = context.locale;\n };\n\n // Only what the view has checked goes into the message a person sends: an id of plain\n // characters and two instants it parsed itself, never a title or any other server text.\n const SAFE_ID = /^[A-Za-z0-9._:-]{1,200}$/;\n const instant = (value) => { const time = typeof value === 'string' ? Date.parse(value) : NaN; return Number.isFinite(time) ? new Date(time) : null; };\n const HOLD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n const pickable = (slot) => {\n if (!slot || typeof slot.resource_id !== 'string' || !SAFE_ID.test(slot.resource_id)) return null;\n const start = instant(slot.start); const end = instant(slot.end);\n if (!start || !end || end <= start) return null;\n if (slot.hold_id === undefined) return { resource_id: slot.resource_id, start, end };\n // A held time (slotlock_hold_slots): booking it names the hold, so it needs a plain id.\n const expires = instant(slot.expires_at);\n return typeof slot.hold_id === 'string' && HOLD_ID.test(slot.hold_id) && expires\n ? { resource_id: slot.resource_id, start, end, hold_id: slot.hold_id, expires }\n : null;\n };\n const slotsOf = (data) => {\n if (!data || typeof data !== 'object' || data.error) return [];\n // Held time is free for this agent alone until it expires: certain by being held.\n if (Array.isArray(data.holds)) return data.holds.map(pickable).filter((slot) => slot && slot.hold_id);\n if (data.coverage?.certainty !== 'certain') return [];\n const listed = Array.isArray(data.slots) ? data.slots : 'start' in data ? [data] : [];\n return listed.map(pickable).filter(Boolean);\n };\n\n const fmt = (date, options) => {\n const zone = state.timeZone ? { timeZone: state.timeZone } : {};\n try { return new Intl.DateTimeFormat(state.locale, { ...options, ...zone }).format(date); }\n catch { return new Intl.DateTimeFormat(undefined, options).format(date); }\n };\n const prefers12 = () => { try { const cycle = new Intl.DateTimeFormat(state.locale, { hour: 'numeric' }).resolvedOptions().hourCycle; return cycle === 'h11' || cycle === 'h12'; } catch { return false; } };\n const hour12 = () => state.hour12 === undefined ? prefers12() : state.hour12;\n const dayKey = (date) => fmt(date, { year: 'numeric', month: '2-digit', day: '2-digit' });\n const dayLabel = (date) => fmt(date, { weekday: 'short', day: 'numeric', month: 'short' });\n const timeLabel = (date) => fmt(date, { hour: hour12() ? 'numeric' : '2-digit', minute: '2-digit', hour12: hour12() });\n // The hour as a number in any locale: German formats it as \"10 Uhr\", so read the hour part.\n const hourOf = (date) => {\n const zone = state.timeZone ? { timeZone: state.timeZone } : {};\n let parts;\n try { parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23', ...zone }).formatToParts(date); }\n catch { parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23' }).formatToParts(date); }\n return Number(parts.find((part) => part.type === 'hour')?.value) % 24;\n };\n const minutes = (slot) => Math.round((slot.end - slot.start) / 60000);\n const lengthLabel = (total) => total < 60 ? total + ' min' : Math.floor(total / 60) + ' h' + (total % 60 ? ' ' + (total % 60) + ' min' : '');\n const sentence = (slot) => 'Book the slot I picked: ' + (slot.hold_id ? 'hold ' + slot.hold_id + ', ' : '') + 'resource ' + slot.resource_id + ', from ' + slot.start.toISOString() + ' to ' + slot.end.toISOString() + ' (' + dayLabel(slot.start) + ', ' + timeLabel(slot.start) + ' to ' + timeLabel(slot.end) + (state.timeZone ? ' ' + state.timeZone : '') + ').';\n\n const renderPicker = (slots) => {\n const resources = [...new Set(slots.map((slot) => slot.resource_id))];\n const lengths = new Set(slots.map(minutes));\n clear(pickMeta);\n if (lengths.size === 1) pickMeta.append(el('span', '', lengthLabel(minutes(slots[0]))));\n if (resources.length > 1) pickMeta.append(el('span', '', resources.length + ' resources'));\n pickMeta.append(el('span', '', state.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local time'));\n const held = slots.filter((slot) => slot.expires).map((slot) => slot.expires).sort((a, b) => a - b)[0];\n if (held) pickMeta.append(el('span', 'held', 'Held until ' + timeLabel(held)));\n h12.setAttribute('aria-pressed', String(hour12())); h24.setAttribute('aria-pressed', String(!hour12()));\n const byDay = new Map();\n slots.forEach((slot) => { const key = dayKey(slot.start); if (!byDay.has(key)) byDay.set(key, []); byDay.get(key).push(slot); });\n if (!byDay.has(state.day)) state.day = byDay.keys().next().value;\n const single = slots.length === 1;\n $('pickTitle').textContent = single ? 'Next available' : 'Pick a time';\n clear(days); clear(times); clear(one);\n days.hidden = single || byDay.size < 2;\n one.hidden = !single;\n times.hidden = single;\n if (single) {\n if (!state.chosen) state.chosen = slots[0];\n one.append(el('strong', '', timeLabel(slots[0].start) + '\u2013' + timeLabel(slots[0].end)), el('span', '', fmt(slots[0].start, { weekday: 'long', day: 'numeric', month: 'long' })));\n } else {\n byDay.forEach((daySlots, key) => {\n const first = daySlots[0].start;\n const button = el('button', 'day');\n button.type = 'button';\n button.setAttribute('aria-pressed', String(key === state.day));\n button.setAttribute('aria-label', dayLabel(first) + ', ' + daySlots.length + (daySlots.length === 1 ? ' time' : ' times'));\n button.append(el('small', '', fmt(first, { weekday: 'short' })), el('strong', '', fmt(first, { day: 'numeric' })), el('i', '', fmt(first, { month: 'short' })));\n button.addEventListener('click', () => { state.day = key; render(); });\n days.append(button);\n });\n const parts = [['Morning', (h) => h < 12], ['Afternoon', (h) => h >= 12 && h < 17], ['Evening', (h) => h >= 17]];\n const daySlots = byDay.get(state.day) || [];\n parts.forEach(([name, test]) => {\n const inPart = daySlots.filter((slot) => test(hourOf(slot.start)));\n if (inPart.length === 0) return;\n const part = el('section', 'part'); const grid = el('div', 'grid');\n part.append(el('h2', '', name), grid);\n inPart.forEach((slot) => {\n const button = el('button', 'time', timeLabel(slot.start));\n button.type = 'button';\n const isChosen = state.chosen && state.chosen.resource_id === slot.resource_id && +state.chosen.start === +slot.start;\n button.setAttribute('aria-pressed', String(Boolean(isChosen)));\n button.setAttribute('aria-label', dayLabel(slot.start) + ', ' + timeLabel(slot.start) + ' to ' + timeLabel(slot.end) + (resources.length > 1 ? ', resource ' + (resources.indexOf(slot.resource_id) + 1) : ''));\n if (resources.length > 1) button.append(el('small', '', 'Resource ' + (resources.indexOf(slot.resource_id) + 1)));\n else if (lengths.size > 1) button.append(el('small', '', lengthLabel(minutes(slot))));\n button.disabled = state.sent;\n button.addEventListener('click', () => { state.chosen = slot; fallback.hidden = true; render(); });\n grid.append(button);\n });\n times.append(part);\n });\n }\n clear(choice);\n choice.hidden = single;\n if (state.chosen) {\n choice.append(el('b', '', dayLabel(state.chosen.start) + ', ' + timeLabel(state.chosen.start) + '\u2013' + timeLabel(state.chosen.end)));\n if (resources.length > 1) choice.append(document.createTextNode('Resource ' + (resources.indexOf(state.chosen.resource_id) + 1)));\n } else choice.textContent = 'No time picked';\n book.disabled = !state.chosen || state.sent;\n book.dataset.sent = String(state.sent);\n book.textContent = state.sent ? '\u2713 Sent to your agent' : 'Book this time';\n };\n book.addEventListener('click', () => {\n if (!state.chosen || state.sent) return;\n state.sent = true;\n fallback.hidden = true;\n const text = sentence(state.chosen);\n const id = state.nextId++;\n state.pending[id] = text;\n setStatus('Sent to your agent', true);\n post({ jsonrpc: '2.0', id, method: 'ui/message', params: { role: 'user', content: { type: 'text', text } } });\n render();\n });\n h12.addEventListener('click', () => { state.hour12 = true; render(); });\n h24.addEventListener('click', () => { state.hour12 = false; render(); });\n\n const reportSize = () => {\n const root = document.documentElement;\n const size = { width: Math.ceil(root.scrollWidth), height: Math.ceil(root.scrollHeight) };\n const key = size.width + 'x' + size.height;\n if (key === state.size) return;\n state.size = key;\n post({ jsonrpc: '2.0', method: 'ui/notifications/size-changed', params: size });\n };\n const intervalText = (entry) => [entry.start || entry.starts_at, entry.end || entry.ends_at].filter(Boolean).join(' \u2192 ');\n // A refused call carries no structuredContent (hosts validate it against the tool's\n // outputSchema); its text content is {\"error\":{\"code\":\u2026}}.\n const refusalOf = (content) => {\n const text = Array.isArray(content) ? content.find((part) => part && part.type === 'text')?.text : undefined;\n try {\n const parsed = JSON.parse(text);\n if (parsed && parsed.error && typeof parsed.error === 'object') return { error: parsed.error };\n } catch {}\n return { error: { code: 'request_refused' } };\n };\n const flatten = (data) => {\n if (!data || typeof data !== 'object') return [];\n if (data.error) return [{ title: data.error.code || 'Unable to load', detail: 'The calendar server refused this request.', badge: 'Error' }];\n if (Array.isArray(data.resources)) return data.resources.flatMap((resource) => {\n if (Array.isArray(resource.busy)) return resource.busy.map((busy) => ({ title: resource.resource_id, detail: intervalText(busy), badge: resource.coverage?.certainty || 'busy' }));\n return [{ title: resource.external_ref || resource.id || resource.resource_id, detail: resource.timezone || 'Calendar resource', badge: resource.coverage?.certainty || 'resource' }];\n });\n if (Array.isArray(data.slots)) {\n if (data.coverage?.certainty !== 'certain') return [{ title: 'Availability unproven', detail: 'Required calendar coverage is incomplete.', badge: data.coverage?.certainty || 'uncertain' }];\n return data.slots.length === 0 ? [{ title: 'No free time', detail: 'Nothing of that length is free in the window.', badge: 'certain' }] : data.slots.map((slot) => ({ title: slot.resource_id, detail: intervalText(slot), badge: 'free' }));\n }\n if (Array.isArray(data.holds)) return [{ title: 'Nothing held', detail: 'Every time asked for was taken.', badge: 'refused' }];\n if (Array.isArray(data.events)) return data.events.map((event) => ({ title: event.title || event.id, detail: intervalText(event), badge: event.status || event.transparency || 'event' }));\n if (data.event && typeof data.event === 'object') return [{ title: data.event.title || data.event.id, detail: intervalText(data.event), badge: data.event.status || 'event' }];\n if ('start' in data || 'end' in data) {\n const coverage = data.coverage && typeof data.coverage === 'object' ? data.coverage : null;\n const certainty = coverage?.certainty;\n const completeInterval = typeof data.start === 'string' && typeof data.end === 'string';\n if (certainty !== 'certain' || !completeInterval) {\n const reason = typeof coverage?.reason === 'string' ? coverage.reason.replaceAll('_', ' ') : 'Required calendar coverage is incomplete.';\n return [{ title: 'Availability unproven', detail: reason, badge: certainty || 'uncertain' }];\n }\n return [{ title: data.resource_id || 'Next available', detail: intervalText(data), badge: certainty }];\n }\n return Object.entries(data).slice(0, 100).map(([key, value]) => ({ title: key, detail: typeof value === 'object' ? JSON.stringify(value) : safeText(value), badge: 'value' }));\n };\n const line = (title, detail, badge) => {\n const row = el('article', 'item');\n row.append(el('div', 'item-title', safeText(title)), el('div', 'item-detail', safeText(detail)), el('div', 'badge', safeText(badge)));\n return row;\n };\n const render = () => {\n const slots = slotsOf(state.data);\n picker.hidden = slots.length === 0;\n list.hidden = slots.length > 0;\n if (slots.length > 0) { renderPicker(slots); reportSize(); return; }\n clear(items);\n const rows = flatten(state.data).filter((entry) => (entry.title + ' ' + entry.detail + ' ' + entry.badge).toLowerCase().includes(state.query));\n if (rows.length === 0) items.append(el('p', 'empty', state.data ? 'No rows match this filter.' : 'Waiting for a calendar result.'));\n else rows.forEach((entry) => items.append(line(entry.title, entry.detail, entry.badge)));\n summary.textContent = rows.length === 1 ? '1 calendar row' : rows.length + ' calendar rows';\n raw.textContent = state.data ? JSON.stringify(state.data, null, 2) : 'No result yet.';\n reportSize();\n };\n filter.addEventListener('input', () => { state.query = filter.value.trim().toLowerCase(); render(); });\n window.addEventListener('message', (event) => {\n if (event.source !== window.parent) return;\n const message = event.data;\n if (!message || message.jsonrpc !== '2.0') return;\n if (message.id === state.requestId && message.result) {\n applyHost(message.result.hostContext);\n post({ jsonrpc: '2.0', method: 'ui/notifications/initialized' });\n setStatus('Connected', true);\n render();\n return;\n }\n if (message.id in state.pending) {\n const text = state.pending[message.id];\n delete state.pending[message.id];\n if (message.error) {\n // Refused or unsupported: the person can still send it, and pick again.\n state.sent = false;\n fallbackText.value = text;\n fallback.hidden = false;\n setStatus('Not sent', false);\n render();\n }\n return;\n }\n if (message.method === 'ui/notifications/tool-result') {\n const result = message.params || {};\n state.data = result.structuredContent || (result.isError ? refusalOf(result.content) : null);\n state.chosen = null; state.sent = false; state.day = null;\n fallback.hidden = true;\n setStatus(result.isError ? 'Request refused' : 'Result current', !result.isError);\n render();\n }\n if (message.method === 'ui/notifications/host-context-changed') { applyHost(message.params); render(); }\n });\n if (typeof ResizeObserver === 'function') new ResizeObserver(reportSize).observe(document.documentElement);\n post({ jsonrpc: '2.0', id: state.requestId, method: 'ui/initialize', params: { appInfo: { name: 'slotlock-calendar', version: '1.1.0' }, appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, protocolVersion: '2026-01-26' } });\n })();\n </script>\n</body>\n</html>";
|
|
14
20
|
export declare const SLOTLOCK_MCP_APP_RESOURCE: Readonly<{
|
|
15
21
|
uri: "ui://slotlock/calendar";
|
|
16
22
|
name: "Slotlock calendar";
|
package/mcp-app.js
CHANGED
|
@@ -20,6 +20,12 @@ export function clientSupportsSlotlockMcpApp(capabilities) {
|
|
|
20
20
|
* `ui/notifications/tool-result` channel; the template contains no tenant or event data and uses
|
|
21
21
|
* textContent for every untrusted value. Keeping this as reviewed source instead of generated UI
|
|
22
22
|
* also lets MCP hosts inspect and cache the exact resource before rendering it.
|
|
23
|
+
*
|
|
24
|
+
* A certain slot (from `slotlock_find_available_slots` or `slotlock_find_next_available`) can be
|
|
25
|
+
* picked: the view sends the host one `ui/message` naming it, in a fixed sentence holding only the
|
|
26
|
+
* resource id and the two instants it checked, so the agent books it through the normal path,
|
|
27
|
+
* where Slotlock asks a person to confirm the write if it is set to. The view itself never calls a
|
|
28
|
+
* tool, and every write tool is visible to the model only.
|
|
23
29
|
*/
|
|
24
30
|
export const SLOTLOCK_MCP_APP_HTML = `<!doctype html>
|
|
25
31
|
<html lang="en">
|
|
@@ -28,56 +34,236 @@ export const SLOTLOCK_MCP_APP_HTML = `<!doctype html>
|
|
|
28
34
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
29
35
|
<title>Slotlock calendar</title>
|
|
30
36
|
<style>
|
|
31
|
-
:root{color-scheme:light dark
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
.
|
|
38
|
-
.
|
|
39
|
-
.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
:root{color-scheme:light dark;--fb-bg:#fbfbf9;--fb-surface:#ffffff;--fb-sunken:#f3f4f0;--fb-text:#171915;--fb-muted:#5d655b;--fb-faint:#8a9187;--fb-line:#e3e5df;--fb-line-strong:#c9cdc4;--fb-inverse:#171915;--fb-on-inverse:#ffffff;--fb-ring:#2e5bff;--fb-ok:#23855b;--fb-warn:#b7791f;--fb-danger:#9b2c2c}
|
|
38
|
+
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--fb-bg:#121411;--fb-surface:#1a1d19;--fb-sunken:#22261f;--fb-text:#eef0ea;--fb-muted:#a9b0a5;--fb-faint:#7d847a;--fb-line:#2c302a;--fb-line-strong:#3d4339;--fb-inverse:#eef0ea;--fb-on-inverse:#121411;--fb-ring:#7d9bff;--fb-ok:#5cc495;--fb-warn:#e0a44a;--fb-danger:#f08a8a}}
|
|
39
|
+
:root[data-theme="dark"]{--fb-bg:#121411;--fb-surface:#1a1d19;--fb-sunken:#22261f;--fb-text:#eef0ea;--fb-muted:#a9b0a5;--fb-faint:#7d847a;--fb-line:#2c302a;--fb-line-strong:#3d4339;--fb-inverse:#eef0ea;--fb-on-inverse:#121411;--fb-ring:#7d9bff;--fb-ok:#5cc495;--fb-warn:#e0a44a;--fb-danger:#f08a8a}
|
|
40
|
+
:root{--bg:var(--color-background-primary,var(--fb-bg));--surface:var(--color-background-secondary,var(--fb-surface));--sunken:var(--color-background-tertiary,var(--fb-sunken));--text:var(--color-text-primary,var(--fb-text));--muted:var(--color-text-secondary,var(--fb-muted));--faint:var(--color-text-tertiary,var(--fb-faint));--line:var(--color-border-primary,var(--fb-line));--line-strong:var(--color-border-secondary,var(--fb-line-strong));--inverse:var(--color-background-inverse,var(--fb-inverse));--on-inverse:var(--color-text-inverse,var(--fb-on-inverse));--ring:var(--color-ring-primary,var(--fb-ring));--ok:var(--color-text-success,var(--fb-ok));--danger:var(--color-text-danger,var(--fb-danger));font-family:var(--font-sans,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif);color:var(--text);background:var(--bg)}
|
|
41
|
+
*{box-sizing:border-box}body{margin:0;min-width:0;background:transparent;-webkit-font-smoothing:antialiased}button,input,textarea{font:inherit;color:inherit}p{margin:0}
|
|
42
|
+
button:focus-visible,input:focus-visible,textarea:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
|
|
43
|
+
.shell{display:grid;gap:12px;padding:16px;max-width:760px;margin:0 auto}
|
|
44
|
+
.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
45
|
+
.status{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}.dot{width:7px;height:7px;border-radius:50%;background:var(--fb-warn)}.status[data-ready="true"] .dot{background:var(--ok)}
|
|
46
|
+
/* The picker */
|
|
47
|
+
.picker{display:grid;border:1px solid var(--line);border-radius:16px;background:var(--surface);overflow:hidden}.picker[hidden]{display:none}
|
|
48
|
+
.head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;padding:16px 16px 12px}
|
|
49
|
+
.head h1{margin:0;font-size:var(--font-heading-sm-size,18px);font-weight:var(--font-weight-semibold,600);letter-spacing:-.01em}
|
|
50
|
+
.meta{margin-top:4px;font-size:13px;color:var(--muted);display:flex;flex-wrap:wrap;gap:4px 10px}.meta span+span::before{content:"·";margin-right:10px;color:var(--faint)}
|
|
51
|
+
.clock{display:inline-flex;padding:2px;border-radius:8px;background:var(--sunken);flex:none}.clock button{min-width:40px;padding:4px 8px;border:0;border-radius:6px;background:transparent;font-size:12px;color:var(--muted);cursor:pointer}.clock button[aria-pressed="true"]{background:var(--surface);color:var(--text);box-shadow:0 1px 2px rgba(0,0,0,.08)}
|
|
52
|
+
.days{display:flex;gap:6px;padding:0 16px 12px;overflow-x:auto;scrollbar-width:none;scroll-snap-type:x proximity}.days::-webkit-scrollbar{display:none}.days[hidden]{display:none}
|
|
53
|
+
.day{flex:none;display:grid;justify-items:center;gap:1px;width:56px;padding:8px 0 7px;border:1px solid var(--line);border-radius:12px;background:transparent;cursor:pointer;scroll-snap-align:start;font-variant-numeric:tabular-nums}
|
|
54
|
+
.day small{font-size:11px;color:var(--muted)}.day strong{font-size:17px;font-weight:var(--font-weight-semibold,600);line-height:1.2}.day i{font-style:normal;font-size:10px;color:var(--faint)}
|
|
55
|
+
.day:hover{border-color:var(--line-strong)}.day[aria-pressed="true"]{background:var(--inverse);border-color:var(--inverse);color:var(--on-inverse)}.day[aria-pressed="true"] small,.day[aria-pressed="true"] i{color:inherit;opacity:.75}
|
|
56
|
+
.times{display:grid;gap:14px;padding:14px 16px 16px;border-top:1px solid var(--line)}.times[hidden],.choice[hidden]{display:none}.foot:has(.choice[hidden]){justify-content:flex-end}
|
|
57
|
+
.part h2{margin:0 0 8px;font-size:11px;font-weight:var(--font-weight-medium,500);letter-spacing:.06em;text-transform:uppercase;color:var(--faint)}
|
|
58
|
+
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}
|
|
59
|
+
.time{display:grid;justify-items:center;gap:1px;min-height:42px;padding:8px 6px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface);cursor:pointer;font-variant-numeric:tabular-nums;font-weight:var(--font-weight-medium,500);transition:border-color .12s,background-color .12s}
|
|
60
|
+
.time small{font-size:10px;font-weight:400;color:var(--muted)}.time:hover:not(:disabled){border-color:var(--text)}
|
|
61
|
+
.time[aria-pressed="true"]{background:var(--inverse);border-color:var(--inverse);color:var(--on-inverse)}.time[aria-pressed="true"] small{color:inherit;opacity:.75}.time:disabled{cursor:default}.time:disabled:not([aria-pressed="true"]){opacity:.45}
|
|
62
|
+
.one{display:grid;gap:4px;padding:4px 16px 16px}.one strong{font-size:var(--font-heading-md-size,22px);font-weight:var(--font-weight-semibold,600);letter-spacing:-.01em;font-variant-numeric:tabular-nums}.one span{font-size:14px;color:var(--muted)}.one[hidden]{display:none}
|
|
63
|
+
.foot{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 16px;border-top:1px solid var(--line);background:var(--sunken)}
|
|
64
|
+
.choice{min-width:0;font-size:13px;color:var(--muted);overflow-wrap:anywhere}.choice b{display:block;color:var(--text);font-weight:var(--font-weight-semibold,600)}
|
|
65
|
+
.book{flex:none;min-height:40px;padding:9px 16px;border:0;border-radius:10px;background:var(--inverse);color:var(--on-inverse);font-weight:var(--font-weight-semibold,600);cursor:pointer}.book:disabled{cursor:default;opacity:.4}.book[data-sent="true"]{opacity:1;background:transparent;color:var(--ok);padding-inline:4px}
|
|
66
|
+
.fallback{display:grid;gap:6px;padding:12px 16px;border-top:1px solid var(--line);font-size:13px;color:var(--muted)}.fallback[hidden]{display:none}.fallback textarea{width:100%;min-height:64px;padding:8px;border:1px solid var(--line-strong);border-radius:8px;background:var(--bg);font:12px/1.5 var(--font-mono,ui-monospace,monospace);resize:vertical}
|
|
67
|
+
/* Every other result */
|
|
68
|
+
.list[hidden]{display:none}.list{display:grid;gap:12px}
|
|
69
|
+
.mast{display:flex;align-items:center;justify-content:space-between;gap:12px}.mast h1{margin:0;font-size:var(--font-heading-sm-size,18px);font-weight:var(--font-weight-semibold,600)}
|
|
70
|
+
.toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.search{flex:1;min-width:min(100%,220px);min-height:38px;padding:8px 12px;border:1px solid var(--line-strong);border-radius:10px;background:var(--surface)}.summary{font-size:13px;color:var(--muted)}
|
|
71
|
+
.panel{min-width:0;border:1px solid var(--line);border-radius:14px;background:var(--surface);overflow:hidden}.items{display:grid;min-width:0}
|
|
72
|
+
.item{display:grid;grid-template-columns:minmax(120px,.85fr) minmax(0,1.5fr) auto;gap:12px;align-items:start;padding:12px 14px;border-bottom:1px solid var(--line)}.item:last-child{border-bottom:0}.item-title{font-weight:var(--font-weight-semibold,600);overflow-wrap:anywhere}.item-detail{min-width:0;color:var(--muted);font-size:13px;line-height:1.45;overflow-wrap:anywhere}.badge{justify-self:end;padding:3px 7px;border:1px solid var(--line);border-radius:6px;font-size:11px;color:var(--muted);white-space:nowrap}.empty{padding:28px 16px;text-align:center;color:var(--muted);font-size:14px}
|
|
73
|
+
details{border-top:1px solid var(--line)}summary{cursor:pointer;padding:10px 14px;color:var(--muted);font-size:12px}pre{margin:0;padding:14px;max-height:260px;overflow:auto;font:12px/1.5 var(--font-mono,ui-monospace,monospace);white-space:pre-wrap;overflow-wrap:anywhere;background:var(--sunken)}
|
|
74
|
+
@media(max-width:520px){.shell{padding:10px}.item{grid-template-columns:1fr}.badge{justify-self:start}.grid{grid-template-columns:repeat(auto-fill,minmax(84px,1fr))}.foot{flex-wrap:wrap}.book{flex:1}}
|
|
75
|
+
@media(prefers-reduced-motion:reduce){.time{transition:none}}
|
|
43
76
|
</style>
|
|
44
77
|
</head>
|
|
45
78
|
<body>
|
|
46
79
|
<main class="shell">
|
|
47
|
-
<
|
|
48
|
-
<div
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
<
|
|
53
|
-
<
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
80
|
+
<section class="picker" id="picker" aria-labelledby="pickTitle" hidden>
|
|
81
|
+
<div class="head">
|
|
82
|
+
<div><h1 id="pickTitle">Pick a time</h1><p class="meta" id="pickMeta"></p></div>
|
|
83
|
+
<div class="clock" role="group" aria-label="Clock"><button type="button" id="h12" aria-pressed="false">12h</button><button type="button" id="h24" aria-pressed="false">24h</button></div>
|
|
84
|
+
</div>
|
|
85
|
+
<div class="days" id="days" role="group" aria-label="Day"></div>
|
|
86
|
+
<div class="one" id="one" hidden></div>
|
|
87
|
+
<div class="times" id="times"></div>
|
|
88
|
+
<div class="foot">
|
|
89
|
+
<p class="choice" id="choice" aria-live="polite">No time picked</p>
|
|
90
|
+
<button type="button" class="book" id="book" disabled>Book this time</button>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="fallback" id="fallback" hidden><p>Your host did not pass the choice on. Send this to your agent:</p><textarea id="fallbackText" readonly></textarea></div>
|
|
93
|
+
</section>
|
|
94
|
+
<section class="list" id="list">
|
|
95
|
+
<header class="mast"><h1>Slotlock calendar</h1><p class="status" id="status" data-ready="false" role="status"><span class="dot" aria-hidden="true"></span><span id="statusText">Connecting to host</span></p></header>
|
|
96
|
+
<div class="toolbar">
|
|
97
|
+
<label class="sr" for="filter">Filter this result</label><input id="filter" class="search" type="search" autocomplete="off" placeholder="Filter by resource, event or time">
|
|
98
|
+
<p class="summary" id="summary" aria-live="polite">Waiting for a calendar result.</p>
|
|
99
|
+
</div>
|
|
100
|
+
<div class="panel">
|
|
101
|
+
<div class="items" id="items"><p class="empty">Ask your agent to list resources, inspect free-busy, find a slot, or list events.</p></div>
|
|
102
|
+
<details><summary>Structured result</summary><pre id="raw">No result yet.</pre></details>
|
|
103
|
+
</div>
|
|
59
104
|
</section>
|
|
60
105
|
</main>
|
|
61
106
|
<script>
|
|
62
107
|
(() => {
|
|
63
108
|
'use strict';
|
|
64
|
-
const state = { data: null, query: '', requestId: 1 };
|
|
65
|
-
const
|
|
66
|
-
const statusText =
|
|
67
|
-
const
|
|
68
|
-
const summary = document.getElementById('summary');
|
|
69
|
-
const raw = document.getElementById('raw');
|
|
70
|
-
const filter = document.getElementById('filter');
|
|
109
|
+
const state = { data: null, query: '', requestId: 1, nextId: 2, timeZone: undefined, locale: undefined, hour12: undefined, day: null, chosen: null, sent: false, pending: {}, size: '' };
|
|
110
|
+
const $ = (id) => document.getElementById(id);
|
|
111
|
+
const status = $('status'), statusText = $('statusText'), items = $('items'), summary = $('summary'), raw = $('raw'), filter = $('filter');
|
|
112
|
+
const picker = $('picker'), list = $('list'), days = $('days'), times = $('times'), one = $('one'), choice = $('choice'), book = $('book'), pickMeta = $('pickMeta'), fallback = $('fallback'), fallbackText = $('fallbackText'), h12 = $('h12'), h24 = $('h24');
|
|
71
113
|
const safeText = (value) => value === null || value === undefined ? '—' : String(value);
|
|
72
114
|
const post = (message) => window.parent.postMessage(message, '*');
|
|
73
115
|
const setStatus = (text, ready) => { statusText.textContent = text; status.dataset.ready = ready ? 'true' : 'false'; };
|
|
74
116
|
const clear = (node) => { while (node.firstChild) node.removeChild(node.firstChild); };
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
117
|
+
const el = (tag, className, text) => { const node = document.createElement(tag); if (className) node.className = className; if (text !== undefined) node.textContent = text; return node; };
|
|
118
|
+
|
|
119
|
+
// The host's look: its standard theme variables (only these names, as plain values) and its theme.
|
|
120
|
+
const THEME_KEYS = /^--(color-(background|text|border|ring)-[a-z]+|font-(sans|mono)|font-weight-[a-z]+|font-(text|heading)-[a-z]+-size)$/;
|
|
121
|
+
const applyHost = (context) => {
|
|
122
|
+
if (!context || typeof context !== 'object') return;
|
|
123
|
+
if (context.theme === 'light' || context.theme === 'dark') document.documentElement.dataset.theme = context.theme;
|
|
124
|
+
const variables = context.styles && context.styles.variables;
|
|
125
|
+
if (variables && typeof variables === 'object') {
|
|
126
|
+
Object.entries(variables).forEach(([key, value]) => {
|
|
127
|
+
if (THEME_KEYS.test(key) && typeof value === 'string' && value.length <= 200 && !/url\\(|expression|[;{}<>]/i.test(value)) document.documentElement.style.setProperty(key, value);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (typeof context.timeZone === 'string') state.timeZone = context.timeZone;
|
|
131
|
+
if (typeof context.locale === 'string') state.locale = context.locale;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Only what the view has checked goes into the message a person sends: an id of plain
|
|
135
|
+
// characters and two instants it parsed itself, never a title or any other server text.
|
|
136
|
+
const SAFE_ID = /^[A-Za-z0-9._:-]{1,200}$/;
|
|
137
|
+
const instant = (value) => { const time = typeof value === 'string' ? Date.parse(value) : NaN; return Number.isFinite(time) ? new Date(time) : null; };
|
|
138
|
+
const HOLD_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
139
|
+
const pickable = (slot) => {
|
|
140
|
+
if (!slot || typeof slot.resource_id !== 'string' || !SAFE_ID.test(slot.resource_id)) return null;
|
|
141
|
+
const start = instant(slot.start); const end = instant(slot.end);
|
|
142
|
+
if (!start || !end || end <= start) return null;
|
|
143
|
+
if (slot.hold_id === undefined) return { resource_id: slot.resource_id, start, end };
|
|
144
|
+
// A held time (slotlock_hold_slots): booking it names the hold, so it needs a plain id.
|
|
145
|
+
const expires = instant(slot.expires_at);
|
|
146
|
+
return typeof slot.hold_id === 'string' && HOLD_ID.test(slot.hold_id) && expires
|
|
147
|
+
? { resource_id: slot.resource_id, start, end, hold_id: slot.hold_id, expires }
|
|
148
|
+
: null;
|
|
149
|
+
};
|
|
150
|
+
const slotsOf = (data) => {
|
|
151
|
+
if (!data || typeof data !== 'object' || data.error) return [];
|
|
152
|
+
// Held time is free for this agent alone until it expires: certain by being held.
|
|
153
|
+
if (Array.isArray(data.holds)) return data.holds.map(pickable).filter((slot) => slot && slot.hold_id);
|
|
154
|
+
if (data.coverage?.certainty !== 'certain') return [];
|
|
155
|
+
const listed = Array.isArray(data.slots) ? data.slots : 'start' in data ? [data] : [];
|
|
156
|
+
return listed.map(pickable).filter(Boolean);
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const fmt = (date, options) => {
|
|
160
|
+
const zone = state.timeZone ? { timeZone: state.timeZone } : {};
|
|
161
|
+
try { return new Intl.DateTimeFormat(state.locale, { ...options, ...zone }).format(date); }
|
|
162
|
+
catch { return new Intl.DateTimeFormat(undefined, options).format(date); }
|
|
163
|
+
};
|
|
164
|
+
const prefers12 = () => { try { const cycle = new Intl.DateTimeFormat(state.locale, { hour: 'numeric' }).resolvedOptions().hourCycle; return cycle === 'h11' || cycle === 'h12'; } catch { return false; } };
|
|
165
|
+
const hour12 = () => state.hour12 === undefined ? prefers12() : state.hour12;
|
|
166
|
+
const dayKey = (date) => fmt(date, { year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
167
|
+
const dayLabel = (date) => fmt(date, { weekday: 'short', day: 'numeric', month: 'short' });
|
|
168
|
+
const timeLabel = (date) => fmt(date, { hour: hour12() ? 'numeric' : '2-digit', minute: '2-digit', hour12: hour12() });
|
|
169
|
+
// The hour as a number in any locale: German formats it as "10 Uhr", so read the hour part.
|
|
170
|
+
const hourOf = (date) => {
|
|
171
|
+
const zone = state.timeZone ? { timeZone: state.timeZone } : {};
|
|
172
|
+
let parts;
|
|
173
|
+
try { parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23', ...zone }).formatToParts(date); }
|
|
174
|
+
catch { parts = new Intl.DateTimeFormat('en-US', { hour: 'numeric', hourCycle: 'h23' }).formatToParts(date); }
|
|
175
|
+
return Number(parts.find((part) => part.type === 'hour')?.value) % 24;
|
|
176
|
+
};
|
|
177
|
+
const minutes = (slot) => Math.round((slot.end - slot.start) / 60000);
|
|
178
|
+
const lengthLabel = (total) => total < 60 ? total + ' min' : Math.floor(total / 60) + ' h' + (total % 60 ? ' ' + (total % 60) + ' min' : '');
|
|
179
|
+
const sentence = (slot) => 'Book the slot I picked: ' + (slot.hold_id ? 'hold ' + slot.hold_id + ', ' : '') + 'resource ' + slot.resource_id + ', from ' + slot.start.toISOString() + ' to ' + slot.end.toISOString() + ' (' + dayLabel(slot.start) + ', ' + timeLabel(slot.start) + ' to ' + timeLabel(slot.end) + (state.timeZone ? ' ' + state.timeZone : '') + ').';
|
|
180
|
+
|
|
181
|
+
const renderPicker = (slots) => {
|
|
182
|
+
const resources = [...new Set(slots.map((slot) => slot.resource_id))];
|
|
183
|
+
const lengths = new Set(slots.map(minutes));
|
|
184
|
+
clear(pickMeta);
|
|
185
|
+
if (lengths.size === 1) pickMeta.append(el('span', '', lengthLabel(minutes(slots[0]))));
|
|
186
|
+
if (resources.length > 1) pickMeta.append(el('span', '', resources.length + ' resources'));
|
|
187
|
+
pickMeta.append(el('span', '', state.timeZone || Intl.DateTimeFormat().resolvedOptions().timeZone || 'Local time'));
|
|
188
|
+
const held = slots.filter((slot) => slot.expires).map((slot) => slot.expires).sort((a, b) => a - b)[0];
|
|
189
|
+
if (held) pickMeta.append(el('span', 'held', 'Held until ' + timeLabel(held)));
|
|
190
|
+
h12.setAttribute('aria-pressed', String(hour12())); h24.setAttribute('aria-pressed', String(!hour12()));
|
|
191
|
+
const byDay = new Map();
|
|
192
|
+
slots.forEach((slot) => { const key = dayKey(slot.start); if (!byDay.has(key)) byDay.set(key, []); byDay.get(key).push(slot); });
|
|
193
|
+
if (!byDay.has(state.day)) state.day = byDay.keys().next().value;
|
|
194
|
+
const single = slots.length === 1;
|
|
195
|
+
$('pickTitle').textContent = single ? 'Next available' : 'Pick a time';
|
|
196
|
+
clear(days); clear(times); clear(one);
|
|
197
|
+
days.hidden = single || byDay.size < 2;
|
|
198
|
+
one.hidden = !single;
|
|
199
|
+
times.hidden = single;
|
|
200
|
+
if (single) {
|
|
201
|
+
if (!state.chosen) state.chosen = slots[0];
|
|
202
|
+
one.append(el('strong', '', timeLabel(slots[0].start) + '–' + timeLabel(slots[0].end)), el('span', '', fmt(slots[0].start, { weekday: 'long', day: 'numeric', month: 'long' })));
|
|
203
|
+
} else {
|
|
204
|
+
byDay.forEach((daySlots, key) => {
|
|
205
|
+
const first = daySlots[0].start;
|
|
206
|
+
const button = el('button', 'day');
|
|
207
|
+
button.type = 'button';
|
|
208
|
+
button.setAttribute('aria-pressed', String(key === state.day));
|
|
209
|
+
button.setAttribute('aria-label', dayLabel(first) + ', ' + daySlots.length + (daySlots.length === 1 ? ' time' : ' times'));
|
|
210
|
+
button.append(el('small', '', fmt(first, { weekday: 'short' })), el('strong', '', fmt(first, { day: 'numeric' })), el('i', '', fmt(first, { month: 'short' })));
|
|
211
|
+
button.addEventListener('click', () => { state.day = key; render(); });
|
|
212
|
+
days.append(button);
|
|
213
|
+
});
|
|
214
|
+
const parts = [['Morning', (h) => h < 12], ['Afternoon', (h) => h >= 12 && h < 17], ['Evening', (h) => h >= 17]];
|
|
215
|
+
const daySlots = byDay.get(state.day) || [];
|
|
216
|
+
parts.forEach(([name, test]) => {
|
|
217
|
+
const inPart = daySlots.filter((slot) => test(hourOf(slot.start)));
|
|
218
|
+
if (inPart.length === 0) return;
|
|
219
|
+
const part = el('section', 'part'); const grid = el('div', 'grid');
|
|
220
|
+
part.append(el('h2', '', name), grid);
|
|
221
|
+
inPart.forEach((slot) => {
|
|
222
|
+
const button = el('button', 'time', timeLabel(slot.start));
|
|
223
|
+
button.type = 'button';
|
|
224
|
+
const isChosen = state.chosen && state.chosen.resource_id === slot.resource_id && +state.chosen.start === +slot.start;
|
|
225
|
+
button.setAttribute('aria-pressed', String(Boolean(isChosen)));
|
|
226
|
+
button.setAttribute('aria-label', dayLabel(slot.start) + ', ' + timeLabel(slot.start) + ' to ' + timeLabel(slot.end) + (resources.length > 1 ? ', resource ' + (resources.indexOf(slot.resource_id) + 1) : ''));
|
|
227
|
+
if (resources.length > 1) button.append(el('small', '', 'Resource ' + (resources.indexOf(slot.resource_id) + 1)));
|
|
228
|
+
else if (lengths.size > 1) button.append(el('small', '', lengthLabel(minutes(slot))));
|
|
229
|
+
button.disabled = state.sent;
|
|
230
|
+
button.addEventListener('click', () => { state.chosen = slot; fallback.hidden = true; render(); });
|
|
231
|
+
grid.append(button);
|
|
232
|
+
});
|
|
233
|
+
times.append(part);
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
clear(choice);
|
|
237
|
+
choice.hidden = single;
|
|
238
|
+
if (state.chosen) {
|
|
239
|
+
choice.append(el('b', '', dayLabel(state.chosen.start) + ', ' + timeLabel(state.chosen.start) + '–' + timeLabel(state.chosen.end)));
|
|
240
|
+
if (resources.length > 1) choice.append(document.createTextNode('Resource ' + (resources.indexOf(state.chosen.resource_id) + 1)));
|
|
241
|
+
} else choice.textContent = 'No time picked';
|
|
242
|
+
book.disabled = !state.chosen || state.sent;
|
|
243
|
+
book.dataset.sent = String(state.sent);
|
|
244
|
+
book.textContent = state.sent ? '✓ Sent to your agent' : 'Book this time';
|
|
245
|
+
};
|
|
246
|
+
book.addEventListener('click', () => {
|
|
247
|
+
if (!state.chosen || state.sent) return;
|
|
248
|
+
state.sent = true;
|
|
249
|
+
fallback.hidden = true;
|
|
250
|
+
const text = sentence(state.chosen);
|
|
251
|
+
const id = state.nextId++;
|
|
252
|
+
state.pending[id] = text;
|
|
253
|
+
setStatus('Sent to your agent', true);
|
|
254
|
+
post({ jsonrpc: '2.0', id, method: 'ui/message', params: { role: 'user', content: { type: 'text', text } } });
|
|
255
|
+
render();
|
|
256
|
+
});
|
|
257
|
+
h12.addEventListener('click', () => { state.hour12 = true; render(); });
|
|
258
|
+
h24.addEventListener('click', () => { state.hour12 = false; render(); });
|
|
259
|
+
|
|
260
|
+
const reportSize = () => {
|
|
261
|
+
const root = document.documentElement;
|
|
262
|
+
const size = { width: Math.ceil(root.scrollWidth), height: Math.ceil(root.scrollHeight) };
|
|
263
|
+
const key = size.width + 'x' + size.height;
|
|
264
|
+
if (key === state.size) return;
|
|
265
|
+
state.size = key;
|
|
266
|
+
post({ jsonrpc: '2.0', method: 'ui/notifications/size-changed', params: size });
|
|
81
267
|
};
|
|
82
268
|
const intervalText = (entry) => [entry.start || entry.starts_at, entry.end || entry.ends_at].filter(Boolean).join(' → ');
|
|
83
269
|
// A refused call carries no structuredContent (hosts validate it against the tool's
|
|
@@ -97,6 +283,11 @@ export const SLOTLOCK_MCP_APP_HTML = `<!doctype html>
|
|
|
97
283
|
if (Array.isArray(resource.busy)) return resource.busy.map((busy) => ({ title: resource.resource_id, detail: intervalText(busy), badge: resource.coverage?.certainty || 'busy' }));
|
|
98
284
|
return [{ title: resource.external_ref || resource.id || resource.resource_id, detail: resource.timezone || 'Calendar resource', badge: resource.coverage?.certainty || 'resource' }];
|
|
99
285
|
});
|
|
286
|
+
if (Array.isArray(data.slots)) {
|
|
287
|
+
if (data.coverage?.certainty !== 'certain') return [{ title: 'Availability unproven', detail: 'Required calendar coverage is incomplete.', badge: data.coverage?.certainty || 'uncertain' }];
|
|
288
|
+
return data.slots.length === 0 ? [{ title: 'No free time', detail: 'Nothing of that length is free in the window.', badge: 'certain' }] : data.slots.map((slot) => ({ title: slot.resource_id, detail: intervalText(slot), badge: 'free' }));
|
|
289
|
+
}
|
|
290
|
+
if (Array.isArray(data.holds)) return [{ title: 'Nothing held', detail: 'Every time asked for was taken.', badge: 'refused' }];
|
|
100
291
|
if (Array.isArray(data.events)) return data.events.map((event) => ({ title: event.title || event.id, detail: intervalText(event), badge: event.status || event.transparency || 'event' }));
|
|
101
292
|
if (data.event && typeof data.event === 'object') return [{ title: data.event.title || data.event.id, detail: intervalText(data.event), badge: data.event.status || 'event' }];
|
|
102
293
|
if ('start' in data || 'end' in data) {
|
|
@@ -111,13 +302,23 @@ export const SLOTLOCK_MCP_APP_HTML = `<!doctype html>
|
|
|
111
302
|
}
|
|
112
303
|
return Object.entries(data).slice(0, 100).map(([key, value]) => ({ title: key, detail: typeof value === 'object' ? JSON.stringify(value) : safeText(value), badge: 'value' }));
|
|
113
304
|
};
|
|
305
|
+
const line = (title, detail, badge) => {
|
|
306
|
+
const row = el('article', 'item');
|
|
307
|
+
row.append(el('div', 'item-title', safeText(title)), el('div', 'item-detail', safeText(detail)), el('div', 'badge', safeText(badge)));
|
|
308
|
+
return row;
|
|
309
|
+
};
|
|
114
310
|
const render = () => {
|
|
311
|
+
const slots = slotsOf(state.data);
|
|
312
|
+
picker.hidden = slots.length === 0;
|
|
313
|
+
list.hidden = slots.length > 0;
|
|
314
|
+
if (slots.length > 0) { renderPicker(slots); reportSize(); return; }
|
|
115
315
|
clear(items);
|
|
116
316
|
const rows = flatten(state.data).filter((entry) => (entry.title + ' ' + entry.detail + ' ' + entry.badge).toLowerCase().includes(state.query));
|
|
117
|
-
if (rows.length === 0)
|
|
317
|
+
if (rows.length === 0) items.append(el('p', 'empty', state.data ? 'No rows match this filter.' : 'Waiting for a calendar result.'));
|
|
118
318
|
else rows.forEach((entry) => items.append(line(entry.title, entry.detail, entry.badge)));
|
|
119
319
|
summary.textContent = rows.length === 1 ? '1 calendar row' : rows.length + ' calendar rows';
|
|
120
320
|
raw.textContent = state.data ? JSON.stringify(state.data, null, 2) : 'No result yet.';
|
|
321
|
+
reportSize();
|
|
121
322
|
};
|
|
122
323
|
filter.addEventListener('input', () => { state.query = filter.value.trim().toLowerCase(); render(); });
|
|
123
324
|
window.addEventListener('message', (event) => {
|
|
@@ -125,19 +326,37 @@ export const SLOTLOCK_MCP_APP_HTML = `<!doctype html>
|
|
|
125
326
|
const message = event.data;
|
|
126
327
|
if (!message || message.jsonrpc !== '2.0') return;
|
|
127
328
|
if (message.id === state.requestId && message.result) {
|
|
329
|
+
applyHost(message.result.hostContext);
|
|
128
330
|
post({ jsonrpc: '2.0', method: 'ui/notifications/initialized' });
|
|
129
331
|
setStatus('Connected', true);
|
|
332
|
+
render();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (message.id in state.pending) {
|
|
336
|
+
const text = state.pending[message.id];
|
|
337
|
+
delete state.pending[message.id];
|
|
338
|
+
if (message.error) {
|
|
339
|
+
// Refused or unsupported: the person can still send it, and pick again.
|
|
340
|
+
state.sent = false;
|
|
341
|
+
fallbackText.value = text;
|
|
342
|
+
fallback.hidden = false;
|
|
343
|
+
setStatus('Not sent', false);
|
|
344
|
+
render();
|
|
345
|
+
}
|
|
130
346
|
return;
|
|
131
347
|
}
|
|
132
348
|
if (message.method === 'ui/notifications/tool-result') {
|
|
133
349
|
const result = message.params || {};
|
|
134
350
|
state.data = result.structuredContent || (result.isError ? refusalOf(result.content) : null);
|
|
351
|
+
state.chosen = null; state.sent = false; state.day = null;
|
|
352
|
+
fallback.hidden = true;
|
|
135
353
|
setStatus(result.isError ? 'Request refused' : 'Result current', !result.isError);
|
|
136
354
|
render();
|
|
137
355
|
}
|
|
138
|
-
if (message.method === 'ui/notifications/host-context-changed'
|
|
356
|
+
if (message.method === 'ui/notifications/host-context-changed') { applyHost(message.params); render(); }
|
|
139
357
|
});
|
|
140
|
-
|
|
358
|
+
if (typeof ResizeObserver === 'function') new ResizeObserver(reportSize).observe(document.documentElement);
|
|
359
|
+
post({ jsonrpc: '2.0', id: state.requestId, method: 'ui/initialize', params: { appInfo: { name: 'slotlock-calendar', version: '1.1.0' }, appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] }, protocolVersion: '2026-01-26' } });
|
|
141
360
|
})();
|
|
142
361
|
</script>
|
|
143
362
|
</body>
|
package/metrics.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SlotlockAgentServerEvent } from './agent-server.js';
|
|
2
|
+
import type { SlotlockMaintenanceResult } from './maintenance.js';
|
|
3
|
+
import type { SlotlockRefusalCounts } from './rate-limit.js';
|
|
4
|
+
import type { SlotlockWebhookPassResult } from './webhooks.js';
|
|
5
|
+
export interface SlotlockMetrics {
|
|
6
|
+
/** Count what the agent server reports (its `onEvent`). */
|
|
7
|
+
event(event: SlotlockAgentServerEvent): void;
|
|
8
|
+
/** Count the request limits' refusals (their `onRefusals`). */
|
|
9
|
+
refusals(counts: SlotlockRefusalCounts): void;
|
|
10
|
+
/** Count a maintenance pass (its `onPass`). */
|
|
11
|
+
maintenance(result: SlotlockMaintenanceResult): void;
|
|
12
|
+
/** Count a webhook delivery pass (its `onPass`). */
|
|
13
|
+
webhooks(result: SlotlockWebhookPassResult): void;
|
|
14
|
+
/** Count a scrape refused for its credential. */
|
|
15
|
+
refusedScrape(): void;
|
|
16
|
+
/** Everything counted so far, as a scrape answers it. */
|
|
17
|
+
render(): string;
|
|
18
|
+
}
|
|
19
|
+
/** Counters for one server process, reset when it restarts (`slotlock_start_time_seconds`). */
|
|
20
|
+
export declare function createSlotlockMetrics(info: {
|
|
21
|
+
version: string;
|
|
22
|
+
tzdata?: string;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
}): SlotlockMetrics;
|