slotlock 0.7.0 → 0.9.0

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +281 -0
  2. package/agent-server.d.ts +25 -5
  3. package/agent-server.js +152 -7
  4. package/agent-store-backend.d.ts +6 -1
  5. package/agent-store-backend.js +861 -55
  6. package/application-api.d.ts +19 -1
  7. package/application-api.js +578 -21
  8. package/approvals.d.ts +2 -0
  9. package/approvals.js +12 -5
  10. package/assistant.d.ts +64 -9
  11. package/assistant.js +450 -82
  12. package/availability-store.d.ts +193 -0
  13. package/availability-store.js +558 -0
  14. package/booking-rules.d.ts +92 -5
  15. package/booking-rules.js +243 -15
  16. package/dashboard-assistant-ui.d.ts +21 -0
  17. package/dashboard-assistant-ui.js +539 -0
  18. package/dashboard-markdown.d.ts +2 -0
  19. package/dashboard-markdown.js +157 -0
  20. package/dashboard-settings-ui.d.ts +1 -0
  21. package/dashboard-settings-ui.js +78 -0
  22. package/dashboard.d.ts +84 -11
  23. package/dashboard.js +1654 -384
  24. package/ddl.d.ts +5 -1
  25. package/ddl.js +760 -9
  26. package/event-feed.d.ts +1 -1
  27. package/event-feed.js +18 -0
  28. package/holiday-import.d.ts +38 -0
  29. package/holiday-import.js +57 -0
  30. package/holidays.d.ts +38 -0
  31. package/holidays.js +150 -0
  32. package/hours.d.ts +125 -0
  33. package/hours.js +224 -0
  34. package/index.d.ts +14 -3
  35. package/index.js +10 -2
  36. package/interop.d.ts +25 -7
  37. package/interop.js +145 -17
  38. package/lifecycle.d.ts +31 -0
  39. package/lifecycle.js +115 -0
  40. package/maintenance.d.ts +19 -0
  41. package/maintenance.js +55 -1
  42. package/metrics.js +3 -0
  43. package/openapi.json +16237 -7795
  44. package/package.json +1 -1
  45. package/ranges.d.ts +28 -0
  46. package/ranges.js +135 -0
  47. package/resource-search.d.ts +19 -0
  48. package/resource-search.js +153 -0
  49. package/rest-api.js +7 -0
  50. package/rules.d.ts +2 -0
  51. package/rules.js +2 -0
  52. package/self-host.d.ts +1 -1
  53. package/self-host.js +221 -21
  54. package/slotlock.manifest.json +1 -1
  55. package/store.d.ts +367 -24
  56. package/store.js +1567 -97
  57. package/types.d.ts +198 -0
package/dashboard.js CHANGED
@@ -16,26 +16,36 @@
16
16
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
17
17
  import { SLOTLOCK_TEST_TENANT_SUFFIX, } from './api-keys.js';
18
18
  import { describeSlotlockAgentWrite, invokeSlotlockAgentOperation, slotlockAgentTools, } from './agent-server.js';
19
- import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_PRESETS, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKeyWithAny, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, listSlotlockAssistantModels, } from './assistant.js';
19
+ import { SLOTLOCK_ASSISTANT_API_KEY, SLOTLOCK_ASSISTANT_MAX_ACTION_BYTES, SLOTLOCK_ASSISTANT_MAX_MESSAGE, SLOTLOCK_ASSISTANT_MAX_PENDING, SLOTLOCK_ASSISTANT_MAX_PREVIOUS_SECRETS, SLOTLOCK_ASSISTANT_MODEL_NAME, SLOTLOCK_ASSISTANT_PRESETS, SLOTLOCK_ASSISTANT_TOOL_USE, boundSlotlockAssistantEntries, createSlotlockAssistantStore, openSlotlockAssistantKeyWithAny, pendingSlotlockAssistantActions, pruneSlotlockAssistantActions, checkSlotlockAssistantHost, readSlotlockAssistantBaseUrl, slotlockAssistantUrlAllowed, readSlotlockAssistantEntries, runSlotlockAssistantTurn, sealSlotlockAssistantKey, slotlockAssistantProviderMessage, SLOTLOCK_ASSISTANT_DIRECTORY_SIZE, slotlockAssistantSystemPrompt, testSlotlockAssistantModel, listSlotlockAssistantModels, } from './assistant.js';
20
20
  import { SLOTLOCK_DASHBOARD_NAME_PATTERN } from './config.js';
21
21
  import { createSlotlockRateLimiter } from './rate-limit.js';
22
22
  import { createSlotlockGuardedFetch } from './webhooks.js';
23
23
  import { lookup as dnsLookup } from 'node:dns/promises';
24
- import { SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
24
+ import { SLOTLOCK_ASSISTANT_CHAT_ID, SLOTLOCK_DASHBOARD_FORM_LIMIT } from './ddl.js';
25
25
  import { ignoreRejection } from './hooks.js';
26
26
  import { SLOTLOCK_BOOKING_RULE_LIMITS } from './booking-rules.js';
27
27
  import { SLOTLOCK_DASHBOARD_ICON_SVG } from './dashboard-icon.js';
28
+ import { SLOTLOCK_SETTINGS_CSS } from './dashboard-settings-ui.js';
29
+ import { renderSlotlockMarkdown } from './dashboard-markdown.js';
30
+ import { SLOTLOCK_ASSISTANT_CSS, slotlockAssistantActionVerb, slotlockAssistantScript, slotlockAssistantStepLabel, } from './dashboard-assistant-ui.js';
28
31
  import { SLOTLOCK_PLAN_LIMITS, SLOTLOCK_PLAN_QUOTAS } from './plans.js';
29
32
  import { SLOTLOCK_MAX_GROUPS_PER_RESOURCE, SLOTLOCK_MAX_LOCATION_DEPTH, SLOTLOCK_MAX_LOCATIONS, SLOTLOCK_MAX_RESOURCE_GROUPS, SLOTLOCK_RESOURCE_KINDS, SLOTLOCK_RESOURCE_PROFILE_LIMITS, } from './resource-profile.js';
30
33
  import { slotlockQuotaPeriod } from './quotas.js';
31
34
  import { slotlockUsageMonthToDate, summarizeSlotlockUsage } from './usage.js';
32
35
  import { SLOTLOCK_CHANGE_TYPES, } from './event-feed.js';
36
+ import { dayToDate } from './hours.js';
37
+ import { SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES } from './holidays.js';
38
+ import { readSlotlockHolidayCalendar } from './holiday-import.js';
33
39
  import { SlotlockAgentOperationError } from './agent-server.js';
34
40
  import { runSlotlockApprovalGrant } from './agent-store-backend.js';
35
41
  import { SLOTLOCK_SANDBOX_SCENARIOS, fastForwardSlotlockSandbox, resetSlotlockSandbox, seedSlotlockSandbox, } from './sandbox.js';
36
42
  import { WEEKDAYS, WEEKDAY_NAMES as DAY_NAMES, rulesToWeeklyHours, weeklyHoursToRules, } from './weekly-hours.js';
37
43
  /** Resources one dashboard tenant may hold. */
38
44
  export const SLOTLOCK_DASHBOARD_MAX_RESOURCES = 500;
45
+ /** Closures the Closures page shows. */
46
+ const SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS = 200;
47
+ /** Contacts the Contacts page shows at once. */
48
+ const SLOTLOCK_DASHBOARD_CONTACTS_PAGE = 100;
39
49
  const SESSION_TTL_MS = 12 * 3_600_000;
40
50
  const SIGN_IN_TTL_MS = 10 * 60_000;
41
51
  /**
@@ -48,6 +58,8 @@ const GOOGLE_KEYS_DEFAULT_TTL_MS = 3_600_000;
48
58
  const MAX_FORM_BYTES = 8_192;
49
59
  /** A message to the assistant: 2,000 characters, each up to 9 bytes as a form encodes it. */
50
60
  const MAX_ASSISTANT_FORM_BYTES = 24_576;
61
+ /** A holiday calendar import's form: the calendar (SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES) and its fields. */
62
+ const MAX_IMPORT_FORM_BYTES = SLOTLOCK_MAX_HOLIDAY_CALENDAR_BYTES + 65_536;
51
63
  /**
52
64
  * Sign-ins (record, then GitHub's token and user) one instance runs at once; more are told to retry.
53
65
  * This also bounds how fast unauthenticated callbacks can write sign-in records.
@@ -84,14 +96,22 @@ const POST_ROUTES = new Set([
84
96
  '/sandbox/reset',
85
97
  '/sandbox/fast-forward',
86
98
  '/hours',
99
+ '/closures',
100
+ '/closures/delete',
101
+ '/closures/import',
102
+ '/closures/calendars/delete',
103
+ '/contacts/forget',
87
104
  '/organization',
88
105
  '/environment',
89
106
  '/teams/enable',
90
107
  '/teams/disable',
91
108
  '/assistant',
109
+ '/assistant/stream',
92
110
  '/assistant/approve',
93
111
  '/assistant/dismiss',
94
112
  '/assistant/reset',
113
+ '/assistant/rename',
114
+ '/assistant/delete',
95
115
  '/settings/assistant',
96
116
  '/settings/assistant/test',
97
117
  '/settings/assistant/models',
@@ -132,7 +152,7 @@ const KEY_EXPIRY_DAYS = Object.freeze({
132
152
  * still sends no referrer to any other site, so the callback URL's code never leaves.
133
153
  */
134
154
  const REFERRER_POLICY = 'same-origin';
135
- const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
155
+ const CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'";
136
156
  /** The policy, with billing's origins allowed as form targets so Upgrade and Manage can redirect. */
137
157
  function policyFor(billing) {
138
158
  if (!billing)
@@ -217,12 +237,49 @@ export function createSlotlockDashboardResources(store, options = {}) {
217
237
  timezone,
218
238
  maxTenantResources: SLOTLOCK_DASHBOARD_MAX_RESOURCES,
219
239
  }), { isolation: 'read committed', ...by(actor) }),
220
- setHours: (tenantRef, target, rules, actor) => store.withTenant(tenantRef, async (tenant) => {
221
- if (target === 'all')
222
- return tenant.setTenantAvailability({ tenantRef, rules });
240
+ setHours: (tenantRef, target, rules, actor, override) => store.withTenant(tenantRef, async (tenant) => {
241
+ if (target === 'all') {
242
+ return (await tenant.storeTenantAvailability({ tenantRef, rules, overrideResources: override ?? 'none' })).inheriting;
243
+ }
244
+ if ('location' in target) {
245
+ const result = await tenant.setLocationAvailability({ tenantRef, location: target.location, rules });
246
+ return result === null ? 0 : Math.max(1, result.inheriting);
247
+ }
223
248
  const updated = await tenant.setResourceAvailability({ tenantRef, id: target.id, rules });
224
249
  return updated ? 1 : 0;
225
250
  }, by(actor)),
251
+ contacts: {
252
+ list: (tenantRef, limit, after) => store.withTenant(tenantRef, (tenant) => tenant.listContacts({ tenantRef, limit, ...(after ? { after } : {}) })),
253
+ forget: (tenantRef, ref, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteContact({ tenantRef, ref }), { isolation: 'read committed', ...by(actor) }),
254
+ },
255
+ hours: {
256
+ defaults: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.getAvailabilityDefaults({ tenantRef })),
257
+ exceptions: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAvailabilityExceptions({
258
+ tenantRef,
259
+ from: dayToDate(Math.floor(Date.now() / 86_400_000) - 1),
260
+ limit,
261
+ })),
262
+ addException: (tenantRef, exception, actor) => store.withTenant(tenantRef, (tenant) => tenant.createAvailabilityException({ ...exception, tenantRef }), by(actor)),
263
+ deleteException: (tenantRef, id, actor) => store.withTenant(tenantRef, (tenant) => tenant.deleteAvailabilityException({ tenantRef, id }), by(actor)),
264
+ importHolidays: async (tenantRef, source, scope, actor) => {
265
+ // Read (a URL fetched) before the transaction opens.
266
+ const read = await readSlotlockHolidayCalendar(source, options.holidayFetch);
267
+ return store.withTenant(tenantRef, async (tenant) => {
268
+ const result = await tenant.importHolidayCalendar({
269
+ tenantRef,
270
+ ...scope,
271
+ name: read.name,
272
+ ...(read.url !== undefined ? { url: read.url } : {}),
273
+ holidays: read.holidays,
274
+ span: read.span,
275
+ });
276
+ return { imported: result.imported, skipped: read.skipped, removed: result.removed };
277
+ }, by(actor));
278
+ },
279
+ byUrl: options.holidayFetch !== undefined,
280
+ calendars: (tenantRef) => store.withTenant(tenantRef, (tenant) => tenant.listHolidayCalendars({ tenantRef })),
281
+ deleteCalendar: (tenantRef, id, actor) => store.withTenant(tenantRef, async (tenant) => (await tenant.deleteHolidayCalendar({ tenantRef, id })) !== null, by(actor)),
282
+ },
226
283
  agenda: (tenantRef, window, limit) => store.withTenant(tenantRef, (tenant) => tenant.listAgenda({ tenantRef, window, limit })),
227
284
  recentChanges: (tenantRef, limit) => store.withTenant(tenantRef, (tenant) => tenant.listRecentChanges({ tenantRef, limit })),
228
285
  setRules: (tenantRef, id, rules, actor) => store.withTenant(tenantRef, (tenant) => tenant.setResourceBookingRules({ tenantRef, id, rules }), by(actor)),
@@ -459,6 +516,11 @@ const RULE_NAMES = Object.freeze({
459
516
  bufferBeforeMinutes: 'Buffer before',
460
517
  turnaroundMinutes: 'Turnaround',
461
518
  checkInMinutes: 'Check-in release',
519
+ minDurationMinutes: 'Shortest booking',
520
+ maxDurationMinutes: 'Longest booking',
521
+ defaultDurationMinutes: 'Usual length',
522
+ cancelCutoffMinutes: 'Cancel cutoff',
523
+ rescheduleCutoffMinutes: 'Move cutoff',
462
524
  });
463
525
  /** The booking rules form's fields, by rule. */
464
526
  const RULE_FIELDS = Object.freeze({
@@ -469,6 +531,11 @@ const RULE_FIELDS = Object.freeze({
469
531
  bufferBeforeMinutes: 'buffer_before_minutes',
470
532
  turnaroundMinutes: 'turnaround_minutes',
471
533
  checkInMinutes: 'check_in_minutes',
534
+ minDurationMinutes: 'min_duration_minutes',
535
+ maxDurationMinutes: 'max_duration_minutes',
536
+ defaultDurationMinutes: 'default_duration_minutes',
537
+ cancelCutoffMinutes: 'cancel_cutoff_minutes',
538
+ rescheduleCutoffMinutes: 'reschedule_cutoff_minutes',
472
539
  });
473
540
  /** How the inherited-rules note names each rule, with its unit. */
474
541
  const INHERITED_NAMES = Object.freeze({
@@ -476,7 +543,28 @@ const INHERITED_NAMES = Object.freeze({
476
543
  maxAdvanceDays: 'Book up to (days ahead)',
477
544
  enforceHours: 'Only inside bookable hours',
478
545
  requireCertain: 'Only in time its calendar syncs have read today',
546
+ allowedDurations: 'Lengths (min)',
547
+ pickupTimes: 'Pick-up times',
548
+ returnTimes: 'Return times',
549
+ cancellationFee: 'Cancellation fee',
550
+ hoursApply: 'Hours hold a hire at',
479
551
  });
552
+ /** A rule's value as the dashboard shows it. */
553
+ function ruleValueText(value) {
554
+ if (typeof value === 'number')
555
+ return value.toLocaleString('en');
556
+ if (typeof value === 'boolean')
557
+ return value ? 'on' : 'off';
558
+ if (typeof value === 'string')
559
+ return value === 'ends' ? 'its start and end' : 'all of it';
560
+ if (!Array.isArray(value)) {
561
+ const fee = value;
562
+ return `${(fee.amountMinor / 100).toFixed(2)} ${fee.currency}${fee.withinMinutes !== undefined ? ` within ${fee.withinMinutes.toLocaleString('en')} min` : ''}`;
563
+ }
564
+ return value
565
+ .map((item) => (typeof item === 'number' ? item.toLocaleString('en') : hoursOf(item.startMinutes, item.durationMinutes)))
566
+ .join(', ');
567
+ }
480
568
  /**
481
569
  * The rules a resource takes from defaults, each with where it comes from: a rule its pool's
482
570
  * defaults set, else one the tenant's set, where its own rules do not. Without the defaults, the
@@ -504,7 +592,7 @@ function inheritedNote(inherited) {
504
592
  return html ``;
505
593
  const parts = [...inherited].map(([name, { value, from }]) => typeof value === 'boolean'
506
594
  ? `${INHERITED_NAMES[name]}${value ? '' : ': off'} (${from})`
507
- : `${INHERITED_NAMES[name]} ${value.toLocaleString('en')} (${from})`);
595
+ : `${INHERITED_NAMES[name]} ${ruleValueText(value)} (${from})`);
508
596
  return html `<p class="fine">Inherited: ${parts.join('; ')}.</p>`;
509
597
  }
510
598
  const icon = (paths) => new Html(`<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${paths}</svg>`);
@@ -660,6 +748,20 @@ const CHANGE_LABELS = Object.freeze({
660
748
  'approval.expired': { text: 'Approval not decided in time', tone: 'bad' },
661
749
  'webhook.disabled': { text: 'Webhook disabled', tone: 'bad' },
662
750
  'usage.threshold_reached': { text: 'Monthly quota nearly or fully used', tone: 'bad' },
751
+ 'usage.period_closed': { text: 'Month of use closed', tone: 'neutral' },
752
+ 'resource.created': { text: 'Resource added', tone: 'good' },
753
+ 'resource.updated': { text: 'Resource changed', tone: 'neutral' },
754
+ 'resource.archived': { text: 'Resource archived', tone: 'neutral' },
755
+ 'resource.restored': { text: 'Resource restored', tone: 'good' },
756
+ 'resource.deleted': { text: 'Resource deleted', tone: 'bad' },
757
+ 'key.created': { text: 'API key created', tone: 'good' },
758
+ 'key.rotated': { text: 'API key rotated', tone: 'neutral' },
759
+ 'key.revoked': { text: 'API key revoked', tone: 'bad' },
760
+ 'webhook.created': { text: 'Webhook added', tone: 'good' },
761
+ 'webhook.enabled': { text: 'Webhook enabled', tone: 'good' },
762
+ 'webhook.deleted': { text: 'Webhook deleted', tone: 'neutral' },
763
+ 'coverage.recorded': { text: 'Calendar synced', tone: 'neutral' },
764
+ 'coverage.deleted': { text: 'Calendar sync removed', tone: 'neutral' },
663
765
  });
664
766
  const zoned = (zone, parts) => {
665
767
  try {
@@ -765,6 +867,10 @@ function formatDate(date) {
765
867
  return '—';
766
868
  return `${date.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
767
869
  }
870
+ /** A UTC day as people write it: `9 Dec 2026`. */
871
+ function dayOf(date) {
872
+ return zoned('UTC', { day: 'numeric', month: 'short', year: 'numeric' }).format(date);
873
+ }
768
874
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
769
875
  /** A table's date: the day (`19 Oct 2026`), with the full UTC time on hover and for machines. */
770
876
  function dateCell(date) {
@@ -894,6 +1000,25 @@ function weekFromForm(form) {
894
1000
  }
895
1001
  return week;
896
1002
  }
1003
+ /** Minutes after midnight for `HH:MM`, or null. */
1004
+ function minutesOf(value) {
1005
+ const match = TIME.exec(value);
1006
+ return match ? Number(match[1]) * 60 + Number(match[2]) : null;
1007
+ }
1008
+ /**
1009
+ * A resource's hours in one line: its own, or what it inherits and from where (RFC 0004 B2), or the
1010
+ * server's.
1011
+ */
1012
+ function resourceHours(resource, server) {
1013
+ if (resource.availabilityRules !== undefined)
1014
+ return hoursSummary(resource.availabilityRules);
1015
+ const inherited = resource.inheritedAvailability;
1016
+ if (inherited) {
1017
+ const from = inherited.source === 'location' ? `${inherited.location ?? 'Location'}'s hours` : 'Your hours';
1018
+ return `${from}: ${hoursSummary(inherited.rules)}`;
1019
+ }
1020
+ return server.length === 0 ? "Server's hours (none)" : `Server's hours: ${hoursSummary(server)}`;
1021
+ }
897
1022
  export function createSlotlockDashboard(options) {
898
1023
  const publicUrl = new URL(options.publicUrl);
899
1024
  const origin = publicUrl.origin;
@@ -920,6 +1045,20 @@ export function createSlotlockDashboard(options) {
920
1045
  }
921
1046
  // Teams on Cloud: organizations turned on in the database, beside every person's own tenant.
922
1047
  const teams = options.organizations === 'enabled' ? (options.teams ?? null) : null;
1048
+ // Plans whether or not billing sells them; billing's own reader when only it is given.
1049
+ const plans = options.plans ??
1050
+ (options.billing
1051
+ ? {
1052
+ plan: options.billing.plan,
1053
+ ...(options.billing.enrol ? { enrol: options.billing.enrol } : {}),
1054
+ contact: options.billing.contact,
1055
+ }
1056
+ : undefined);
1057
+ if (plans?.contact) {
1058
+ const protocol = new URL(plans.contact).protocol;
1059
+ if (protocol !== 'https:' && protocol !== 'mailto:')
1060
+ throw new Error('plans.contact must be an https or mailto URL');
1061
+ }
923
1062
  if (options.organizations === 'enabled' && !teams) {
924
1063
  throw new Error('Slotlock dashboard organizations "enabled" needs the teams store');
925
1064
  }
@@ -1106,9 +1245,7 @@ export function createSlotlockDashboard(options) {
1106
1245
  ...(assistantStore()
1107
1246
  ? [{ section: 'assistant', label: 'Assistant', path: '/assistant', icon: ICONS.assistant }]
1108
1247
  : []),
1109
- ...(options.billing
1110
- ? [{ section: 'plan', label: 'Plan', path: '/plan', icon: ICONS.plan }]
1111
- : []),
1248
+ ...(plans ? [{ section: 'plan', label: 'Usage', path: '/plan', icon: ICONS.plan }] : []),
1112
1249
  ...(teams ? [{ section: 'teams', label: 'Teams', path: '/teams', icon: ICONS.teams }] : []),
1113
1250
  { section: 'settings', label: 'Settings', path: '/settings', icon: ICONS.settings },
1114
1251
  ];
@@ -1177,13 +1314,19 @@ ${live}
1177
1314
  </body>
1178
1315
  </html>`;
1179
1316
  }
1180
- const noticeOf = (notice) => notice ? html `<p class="notice" role="alert">${notice}</p>` : html ``;
1317
+ // A notice is an alert only when the page answers with an error: a done or saved message is a
1318
+ // status, so success is never painted in the error colour.
1319
+ const noticeOf = (notice, status = 200) => notice
1320
+ ? status >= 400
1321
+ ? html `<p class="notice" role="alert">${notice}</p>`
1322
+ : html `<p class="notice done" role="status">${notice}</p>`
1323
+ : html ``;
1181
1324
  function signedOutPage(status, notice, cookies = []) {
1182
1325
  const content = html `<section class="sign-in">
1183
1326
  <span class="sign-in-mark">${ICONS.mark}</span>
1184
1327
  <h1>Sign in to ${brandName}</h1>
1185
1328
  <p>Create keys for your agents, add what they book, and watch what they do.</p>
1186
- ${noticeOf(notice)}
1329
+ ${noticeOf(notice, status)}
1187
1330
  <a class="button primary wide" href="${root}/sign-in">${ICONS.github}Continue with GitHub</a>${google ? html `<a class="button wide" href="${root}/sign-in?with=google">${ICONS.google}Continue with Google</a>` : html ``}
1188
1331
  ${options.termsUrl
1189
1332
  ? html `<p class="fine">By continuing you agree to the <a href="${options.termsUrl}">Terms of Service</a>.${options.privacyUrl
@@ -1273,74 +1416,209 @@ ${options.termsUrl
1273
1416
  }
1274
1417
  return html `${[...groups.values()].map((group) => html `<h3 class="day">${group.label}</h3><ul class="slots">${group.rows}</ul>`)}${more ? html `<p class="fine">Showing the first ${String(AGENDA_SHOWN)}.</p>` : html ``}`;
1275
1418
  }
1419
+ /**
1420
+ * What each change says in the feed, as a sentence: who did it (`verb` then the resource), or what
1421
+ * happened (`passive`, the resource in place of {X}). Every change type the feed records has one,
1422
+ * so the feed never shows a raw type such as resource.updated.
1423
+ */
1424
+ const ACTIVITY = Object.freeze({
1425
+ 'hold.created': { verb: 'held' },
1426
+ 'hold.updated': { verb: 'changed the hold on' },
1427
+ 'hold.confirmed': { verb: 'booked', after: ' from a hold' },
1428
+ 'hold.released': { verb: 'released the hold on' },
1429
+ 'hold.expired': { passive: 'The hold on {X} expired' },
1430
+ 'reservation.created': { verb: 'booked' },
1431
+ 'reservation.updated': { verb: 'moved a booking on' },
1432
+ 'reservation.cancelled': { verb: 'cancelled a booking on' },
1433
+ 'reservation.checked_in': { verb: 'checked in to' },
1434
+ 'reservation.no_show': { passive: '{X} was released after a no-show' },
1435
+ 'event.created': { verb: 'added an event on' },
1436
+ 'event.updated': { verb: 'changed an event on' },
1437
+ 'event.cancelled': { verb: 'cancelled an event on' },
1438
+ 'event.timezone_shifted': { passive: 'An event on {X} moved with a time zone change' },
1439
+ 'event.timezone_conflict': { passive: 'An event on {X} clashes after a time zone change' },
1440
+ 'coverage.stale': { passive: 'The calendar sync for {X} is behind' },
1441
+ 'coverage.recorded': { passive: 'The calendar for {X} synced' },
1442
+ 'coverage.deleted': { verb: 'removed the calendar sync for' },
1443
+ 'waitlist.offered': { passive: 'A waitlist offer went out for {X}' },
1444
+ 'waitlist.expired': { passive: 'A waitlist offer for {X} lapsed' },
1445
+ 'approval.requested': { passive: 'A booking on {X} waits for approval' },
1446
+ 'approval.granted': { verb: 'approved a booking on' },
1447
+ 'approval.declined': { verb: 'declined a booking on' },
1448
+ 'approval.expired': { passive: 'A booking on {X} was not approved in time' },
1449
+ 'resource.created': { verb: 'added' },
1450
+ 'resource.updated': { verb: 'edited' },
1451
+ 'resource.archived': { verb: 'archived' },
1452
+ 'resource.restored': { verb: 'restored' },
1453
+ 'resource.deleted': { verb: 'deleted' },
1454
+ 'key.created': { verb: 'created an API key' },
1455
+ 'key.rotated': { verb: 'rotated an API key' },
1456
+ 'key.revoked': { verb: 'revoked an API key' },
1457
+ 'webhook.created': { verb: 'added a webhook' },
1458
+ 'webhook.enabled': { verb: 'turned a webhook back on' },
1459
+ 'webhook.disabled': { passive: 'A webhook was turned off' },
1460
+ 'webhook.deleted': { verb: 'deleted a webhook' },
1461
+ 'usage.period_closed': { passive: 'A month of use closed' },
1462
+ });
1463
+ /** Changes about a resource, which name it; the rest stand alone. */
1464
+ const ABOUT_A_RESOURCE = /^(hold|reservation|event|coverage|waitlist|approval|resource)\./;
1276
1465
  function activityPanel(changes, byId, keys, session) {
1277
1466
  if (changes.length === 0) {
1278
1467
  return html `<p class="empty">No activity yet. What agents book, move and cancel shows here.</p>`;
1279
1468
  }
1280
1469
  const keyNames = new Map(keys.map((key) => [`api_key:${key.id}`, key.name]));
1281
- return html `<ol class="feed">${changes.map((change) => {
1282
- const label = CHANGE_LABELS[change.type] ?? { text: change.type, tone: 'neutral' };
1283
- const who = change.actor === null
1284
- ? brandName
1285
- : change.actor === personOf(session)
1286
- ? 'You'
1287
- : (keyNames.get(change.actor) ??
1288
- (change.actor.startsWith('api_key:')
1289
- ? 'A deleted key'
1290
- : /^github:\d+$/.test(change.actor)
1291
- ? `GitHub user ${change.actor.slice(7)}`
1292
- : /^google:/.test(change.actor)
1293
- ? 'A Google user'
1294
- : /^dashboard-assistant:(github|google):/.test(change.actor)
1295
- ? change.actor === assistantActor(session)
1296
- ? `Assistant for ${shownAs(session)}`
1297
- : change.actor.startsWith('dashboard-assistant:google:')
1298
- ? 'Assistant for a Google user'
1299
- : `Assistant for GitHub user ${change.actor.slice(27)}`
1300
- : change.actor.slice(0, 60)));
1301
- const where = change.resourceId
1302
- ? resourceName(byId.get(change.resourceId), change.resourceId)
1303
- : null;
1304
- return html `<li class="event"><span class="dot ${label.tone}" aria-hidden="true"></span><span class="what" title="${where ? `${label.text} on ${where}` : label.text}"><span class="title">${label.text}${where ? html ` <span class="on">on</span> ${where}` : html ``}</span><span class="meta">${who} · <time datetime="${change.occurredAt.toISOString()}" title="${formatDate(change.occurredAt)}">${ago(change.occurredAt, now())}</time></span></span></li>`;
1470
+ const whoOf = (actor) => actor === null
1471
+ ? brandName
1472
+ : actor === personOf(session)
1473
+ ? 'You'
1474
+ : (keyNames.get(actor) ??
1475
+ (actor.startsWith('api_key:')
1476
+ ? 'A deleted key'
1477
+ : /^github:\d+$/.test(actor)
1478
+ ? `GitHub user ${actor.slice(7)}`
1479
+ : /^google:/.test(actor)
1480
+ ? 'A Google user'
1481
+ : /^dashboard-assistant:(github|google):/.test(actor)
1482
+ ? actor === assistantActor(session)
1483
+ ? `Assistant for ${shownAs(session)}`
1484
+ : actor.startsWith('dashboard-assistant:google:')
1485
+ ? 'Assistant for a Google user'
1486
+ : `Assistant for GitHub user ${actor.slice(27)}`
1487
+ : actor.slice(0, 60)));
1488
+ // Repeats in a row (one actor, one change, one resource) read as one line with a count.
1489
+ const runs = [];
1490
+ for (const change of changes) {
1491
+ const last = runs.at(-1);
1492
+ if (last && last.change.actor === change.actor && last.change.type === change.type && last.change.resourceId === change.resourceId) {
1493
+ last.times += 1;
1494
+ }
1495
+ else {
1496
+ runs.push({ change, times: 1 });
1497
+ }
1498
+ }
1499
+ // Own entries only: a type such as `constructor` names nothing here.
1500
+ const own = (map, type) => (Object.hasOwn(map, type) ? map[type] : undefined);
1501
+ return html `<ol class="feed">${runs.map(({ change, times }) => {
1502
+ const tone = own(CHANGE_LABELS, change.type)?.tone ?? 'neutral';
1503
+ const where = change.resourceId ? resourceName(byId.get(change.resourceId), change.resourceId) : null;
1504
+ const object = where ?? (ABOUT_A_RESOURCE.test(change.type) ? 'a resource' : null);
1505
+ const words = own(ACTIVITY, change.type);
1506
+ let sentence;
1507
+ if (change.type === 'usage.threshold_reached') {
1508
+ const metric = typeof change.data.metric === 'string' ? change.data.metric : 'use';
1509
+ const percent = typeof change.data.percent === 'number' ? change.data.percent : null;
1510
+ sentence = html `${percent === null ? 'A monthly quota' : `${String(percent)}% of this month's ${metric}`} used`;
1511
+ }
1512
+ else if (words?.verb) {
1513
+ sentence = html `<strong>${whoOf(change.actor)}</strong> ${words.verb}${object ? html ` <strong>${object}</strong>` : html ``}${words.after ?? ''}`;
1514
+ }
1515
+ else if (words?.passive) {
1516
+ const [before, rest] = words.passive.split('{X}');
1517
+ sentence = rest === undefined ? html `${before}` : html `${before}<strong>${object ?? 'a resource'}</strong>${rest}`;
1518
+ }
1519
+ else {
1520
+ // A change this dashboard has no words for (a newer server's): its label, never its type.
1521
+ const label = own(CHANGE_LABELS, change.type)?.text;
1522
+ sentence = label
1523
+ ? html `${label}${object ? html ` <span class="on">on</span> <strong>${object}</strong>` : html ``}`
1524
+ : html `Something changed${where ? html ` on <strong>${where}</strong>` : html ``}`;
1525
+ }
1526
+ return html `<li class="event"><span class="dot ${tone}" aria-hidden="true"></span><span class="what"><span class="title">${sentence}${times > 1 ? html ` <span class="times">${times === 2 ? 'twice' : `${String(times)} times`}</span>` : html ``}</span></span><time class="when" datetime="${change.occurredAt.toISOString()}" title="${formatDate(change.occurredAt)}">${ago(change.occurredAt, now())}</time></li>`;
1305
1527
  })}</ol>`;
1306
1528
  }
1529
+ /** The MCP and A2A endpoints with Copy, and the command that adds Slotlock to an MCP client. */
1530
+ const connectAgent = () => html `${endpoints()}<details class="command"><summary>Claude Code command</summary><div class="copy-block"><pre><code id="mcp-command">claude mcp add --transport http slotlock ${mcpUrl} --header "Authorization: Bearer YOUR_KEY"</code></pre><button class="quiet" type="button" data-copy="mcp-command">Copy</button></div></details>`;
1307
1531
  async function overviewPage(session, status = 200, notice) {
1308
1532
  const tenantRef = tenantOf(session);
1309
1533
  const start = new Date(now());
1310
1534
  const window = { start, end: new Date(start.getTime() + AGENDA_DAYS * DAY_MS) };
1311
- const [keys, resources, agenda, changes] = await Promise.all([
1535
+ const assistant = assistantStore();
1536
+ const [keys, resources, agenda, changes, approvals, waitlist, model] = await Promise.all([
1312
1537
  options.keys.list({ tenantRef }),
1313
1538
  options.resources.list(tenantRef),
1314
1539
  options.resources.agenda?.(tenantRef, window, AGENDA_LIMIT),
1315
1540
  options.resources.recentChanges?.(tenantRef, ACTIVITY_LIMIT),
1541
+ options.resources.approvals?.list(tenantRef, APPROVALS_SHOWN),
1542
+ options.resources.waitlist?.list(tenantRef, WAITLIST_SHOWN),
1543
+ assistant?.getSettings(tenantRef),
1316
1544
  ]);
1545
+ // The plan is Live's: the test environment has none of its own.
1546
+ const standing = plans && session.env !== 'test' ? await planStanding(session, 'overview', keys) : null;
1317
1547
  const byId = new Map(resources.map((resource) => [resource.id, resource]));
1318
1548
  const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
1319
1549
  const counted = (count) => agenda && agenda.length >= AGENDA_LIMIT ? `${count}+` : String(count);
1320
1550
  const booked = agenda?.filter((entry) => entry.kind !== 'hold').length ?? 0;
1321
1551
  const held = agenda?.filter((entry) => entry.kind === 'hold').length ?? 0;
1322
- const stat = (label, value, href) => html `<a class="stat" href="${href}"><span class="stat-label">${label}</span><span class="stat-value">${value}</span></a>`;
1552
+ const waiting = approvals?.filter((approval) => approval.status === 'pending').length ?? 0;
1553
+ const attention = [
1554
+ ...(standing?.attention ?? []),
1555
+ ...(waiting > 0
1556
+ ? [
1557
+ attentionItem('info', `${String(waiting)} ${waiting === 1 ? 'booking waits' : 'bookings wait'} for your approval.`, {
1558
+ href: `${root}/approvals`,
1559
+ label: 'Review',
1560
+ }),
1561
+ ]
1562
+ : []),
1563
+ ];
1564
+ const admin = isAdmin(session);
1323
1565
  const steps = [
1324
- { done: resources.length > 0, text: 'Add what your agents book', href: `${root}/resources` },
1325
- { done: active.length > 0, text: 'Create a key for an agent', href: `${root}/keys` },
1326
- { done: (changes?.length ?? 0) > 0, text: 'Connect the agent and book', href: `${root}/keys` },
1566
+ { done: resources.length > 0, title: 'Add a resource', action: { href: `${root}/resources`, label: 'Add resource' } },
1567
+ { done: active.length > 0, title: 'Create an API key', action: { href: `${root}/keys#new-key`, label: 'Create key' } },
1568
+ // Done once an agent has used a key.
1569
+ { done: active.some((key) => key.lastUsedAt !== null), title: 'Connect an agent', action: { href: `${root}#connect`, label: 'Connect' } },
1570
+ ...(assistant
1571
+ ? [
1572
+ admin
1573
+ ? { done: Boolean(model), title: 'Connect a model to the assistant', action: { href: `${root}/settings`, label: 'Connect' } }
1574
+ : { done: Boolean(model), title: 'Try the assistant', action: { href: `${root}/assistant`, label: 'Open' } },
1575
+ ]
1576
+ : []),
1327
1577
  ];
1328
- const content = html `${noticeOf(notice)}
1329
- ${pageHead('Overview')}
1330
- ${steps.every((step) => step.done)
1578
+ const done = steps.filter((step) => step.done).length;
1579
+ const next = steps.findIndex((step) => !step.done);
1580
+ // Nothing made and nothing happened yet: only the way in.
1581
+ const fresh = resources.length === 0 && active.length === 0 && (changes?.length ?? 0) === 0;
1582
+ const setupCard = next === -1
1331
1583
  ? html ``
1332
- : html `<section class="panel setup"><h2>Get started</h2><ol class="steps">${steps.map((step) => html `<li class="${step.done ? 'done' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span>${step.done ? html `<span>${step.text}</span>` : html `<a href="${step.href}">${step.text}</a>`}${step.done ? html `<span class="visually-hidden">(done)</span>` : html ``}</li>`)}</ol></section>`}
1333
- <div class="stats">
1334
- ${agenda ? stat(`Booked, next ${AGENDA_DAYS} days`, counted(booked), `${root}#upcoming`) : html ``}
1335
- ${agenda ? stat('Live holds', counted(held), `${root}#upcoming`) : html ``}
1336
- ${stat('Resources', String(resources.length), `${root}/resources`)}
1337
- ${stat('Active keys', String(active.length), `${root}/keys`)}
1338
- </div>
1584
+ : html `<section class="panel setup-card"><div class="panel-head"><h2>Get started</h2><span class="meta">${String(done)} of ${String(steps.length)}</span></div><div class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</div><ol class="setup-steps">${steps.map((step, index) => html `<li class="${step.done ? 'done' : index === next ? 'next' : ''}"><span class="check" aria-hidden="true">${step.done ? ICONS.check : html ``}</span><span class="step-title">${step.title}${step.done ? html `<span class="visually-hidden"> (done)</span>` : html ``}</span>${index === next ? html `<a class="button primary" href="${step.action.href}">${step.action.label}</a>` : html ``}</li>`)}</ol></section>`;
1585
+ // Mostly set up: one line that leads to the next step.
1586
+ const setupRow = next === -1
1587
+ ? html ``
1588
+ : html `<a class="setup-row" href="${steps[next]?.action.href ?? root}"><span class="segments" aria-hidden="true">${steps.map((step) => html `<span${step.done ? html ` class="done"` : html ``}></span>`)}</span><span>Get started · ${String(done)} of ${String(steps.length)} · <strong>${steps[next]?.title ?? ''}</strong></span></a>`;
1589
+ const stat = (label, value, href, tone = '') => html `<a class="stat${tone}" href="${href}"><span class="stat-label">${label}</span><span class="stat-value">${value}</span></a>`;
1590
+ const tiles = [
1591
+ ...(agenda ? [stat(`Bookings, next ${AGENDA_DAYS} days`, counted(booked), `${root}#upcoming`), stat('Live holds', counted(held), `${root}#upcoming`)] : []),
1592
+ ...(approvals ? [stat('Waiting for approval', String(waiting), `${root}/approvals`, waiting > 0 ? ' attention-stat' : '')] : []),
1593
+ ...(waitlist ? [stat('On the waitlist', String(waitlist.filter((entry) => entry.status !== 'expired').length), `${root}/waitlist`)] : []),
1594
+ stat('Resources', String(resources.length), `${root}/resources`),
1595
+ stat('Active keys', String(active.length), `${root}/keys`),
1596
+ ].slice(0, 4);
1597
+ const planCard = standing
1598
+ ? html `<section class="panel plan-card"><div class="panel-head"><h2>${standing.name}</h2><a href="${root}/plan">Usage</a></div><div class="meters stacked">${standing.meters}</div>${standing.canUpgrade && standing.pressing
1599
+ ? html `<a class="button primary" href="${root}/plan#upgrade">Upgrade</a>`
1600
+ : html ``}</section>`
1601
+ : html ``;
1602
+ const connectCard = html `<section class="panel" id="connect"><h2>Connect an agent</h2>${connectAgent()}</section>`;
1603
+ const body = fresh
1604
+ ? standing
1605
+ ? html `<div class="split">${setupCard}<div class="column">${planCard}</div></div>`
1606
+ : setupCard
1607
+ : html `${done * 2 >= steps.length ? setupRow : setupCard}
1608
+ <div class="stats">${tiles}</div>
1339
1609
  ${session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
1340
1610
  <div class="split">
1611
+ <div class="column">
1341
1612
  ${agenda ? html `<section class="panel" id="upcoming"><h2>Upcoming</h2>${agendaPanel(agenda, byId, agenda.length > AGENDA_SHOWN)}</section>` : html ``}
1342
- ${changes ? html `<section class="panel"><h2>Agent activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
1613
+ ${changes ? html `<section class="panel"><h2>Activity</h2>${activityPanel(changes, byId, keys, session)}</section>` : html ``}
1614
+ </div>
1615
+ <div class="column">${planCard}${connectCard}</div>
1343
1616
  </div>`;
1617
+ const content = html `${noticeOf(notice, status)}
1618
+ ${pageHead('Overview')}
1619
+ ${attentionList(attention)}
1620
+ ${fresh && session.env === 'test' && options.resources.sandbox ? sandboxPanel(session, resources.length) : html ``}
1621
+ ${body}`;
1344
1622
  return respond(status, layout('Overview', content, session, 'overview'));
1345
1623
  }
1346
1624
  /** In Test: load a scenario to try an agent against, move waiting forward, or start again (RFC 0004 A6). */
@@ -1404,7 +1682,7 @@ ${resourceCount === 0
1404
1682
  const limitChoices = (locations?.length ?? 0) + (groups?.length ?? 0) > 0
1405
1683
  ? html `<details class="types"><summary>Limit to locations or groups (optional)</summary><div class="checks">${(locations ?? []).map((location) => html `<label class="choice"><input type="checkbox" name="limit_location" value="${location.ref}"> ${paths.get(location.ref) ?? location.name}</label>`)}${(groups ?? []).map((group) => html `<label class="choice"><input type="checkbox" name="limit_group" value="${group.ref}"> Group: ${group.name}</label>`)}</div><p class="fine">The key then reaches only the resources there (a location with every location in it), read and write only. Developer plan and up.</p></details>`
1406
1684
  : html ``;
1407
- const content = html `${noticeOf(notice)}
1685
+ const content = html `${noticeOf(notice, status)}
1408
1686
  ${pageHead('API keys')}
1409
1687
  <section class="panel">
1410
1688
  <h2>Connect an agent</h2>
@@ -1435,7 +1713,7 @@ ${keys.length === 0
1435
1713
  * dashboard's own limit, or its plan's when lower (archived resources count against a plan).
1436
1714
  */
1437
1715
  async function resourceAllowance(tenantRef, resources) {
1438
- const plan = options.billing ? await options.billing.plan(tenantRef) : null;
1716
+ const plan = plans ? await plans.plan(tenantRef) : null;
1439
1717
  if (!plan || plan.limits.resources >= SLOTLOCK_DASHBOARD_MAX_RESOURCES) {
1440
1718
  return { used: resources.length, limit: SLOTLOCK_DASHBOARD_MAX_RESOURCES, planned: false, archived: 0 };
1441
1719
  }
@@ -1447,12 +1725,16 @@ ${keys.length === 0
1447
1725
  archived: Math.max(0, counted - resources.length),
1448
1726
  };
1449
1727
  }
1450
- async function resourcesPage(session, status = 200, notice) {
1451
- const resources = await options.resources.list(tenantOf(session));
1452
- const allowance = await resourceAllowance(tenantOf(session), resources);
1728
+ async function resourcesPage(session, status = 200, notice, ownHoursOnly = false) {
1729
+ const every = await options.resources.list(tenantOf(session));
1730
+ const allowance = await resourceAllowance(tenantOf(session), every);
1731
+ // `?hours=own`: those that keep hours of their own, which the tenant's hours do not reach.
1732
+ const resources = ownHoursOnly
1733
+ ? every.filter((resource) => resource.archivedAt === undefined && resource.availabilityRules !== undefined)
1734
+ : every;
1453
1735
  const token = csrfToken(session);
1454
- const content = html `${noticeOf(notice)}
1455
- ${pageHead('Resources', html `<div class="row-actions">${options.resources.grouping ? html `<a class="button" href="${root}/locations">Locations and groups</a>` : html ``}${resources.length > 0 ? html `<a class="button" href="${root}/hours">Set hours for every resource</a>` : html ``}</div>`)}
1736
+ const content = html `${noticeOf(notice, status)}
1737
+ ${pageHead('Resources', html `<div class="row-actions">${options.resources.grouping ? html `<a class="button" href="${root}/locations">Locations and groups</a>` : html ``}<a class="button" href="${root}/hours">Your hours</a>${options.resources.hours ? html `<a class="button" href="${root}/closures">Closures</a>` : html ``}${options.resources.contacts ? html `<a class="button" href="${root}/contacts">Contacts</a>` : html ``}</div>`)}
1456
1738
  <section class="panel" id="add-resource">
1457
1739
  <h2>Add a resource</h2>
1458
1740
  <form class="create" method="post" action="${root}/resources">
@@ -1464,9 +1746,11 @@ ${pageHead('Resources', html `<div class="row-actions">${options.resources.group
1464
1746
  </form>
1465
1747
  <p class="fine">${String(allowance.used)} of ${String(allowance.limit)}${allowance.planned ? html ` on your plan${allowance.archived > 0 ? html `, ${String(allowance.archived)} of them archived (archived resources count until deleted)` : html ``}` : html ``}. Adding a reference you have changes its time zone.</p>
1466
1748
  </section>
1467
- ${resources.length === 0
1749
+ ${ownHoursOnly
1750
+ ? html `<p class="notice" role="status">${String(resources.length)} ${resources.length === 1 ? 'resource keeps hours of its own' : 'resources keep hours of their own'}, so <a href="${root}/hours">your hours</a> do not reach ${resources.length === 1 ? 'it' : 'them'}. <a href="${root}/resources">Show every resource</a></p>`
1751
+ : html ``}${resources.length === 0 && !ownHoursOnly
1468
1752
  ? emptyState(ICONS.resources, 'No resources yet. Agents can only book what is listed here.', { href: '#reference', label: 'Add a resource' })
1469
- : html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference" class="strong"><span class="cell"><a class="truncate" href="${root}/resources/${resource.id}" title="${resourceName(resource, resource.id)}">${resourceName(resource, resource.id)}</a>${resource.name && resource.externalRef ? html `<span class="meta" title="Reference ${resource.externalRef}">${resource.externalRef}</span>` : html ``}${resource.pool ? html `<span class="meta" title="Pool ${resource.pool}">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours"><span class="cell"><span>${hoursSummary(resource.availabilityRules)}</span><a class="meta-link" href="${root}/hours?resource=${resource.id}">Set hours</a></span></td><td data-label="Resource id"><code class="muted truncate id" title="${resource.id}">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
1753
+ : html `<div class="table"><table><thead><tr><th>Reference</th><th>Time zone</th><th>Bookable hours</th><th>Resource id</th></tr></thead><tbody>${resources.map((resource) => html `<tr><td data-label="Reference" class="strong"><span class="cell"><a class="truncate" href="${root}/resources/${resource.id}" title="${resourceName(resource, resource.id)}">${resourceName(resource, resource.id)}</a>${resource.name && resource.externalRef ? html `<span class="meta" title="Reference ${resource.externalRef}">${resource.externalRef}</span>` : html ``}${resource.pool ? html `<span class="meta" title="Pool ${resource.pool}">Pool ${resource.pool}</span>` : html ``}</span></td><td data-label="Time zone">${resource.timezone}</td><td data-label="Bookable hours"><span class="cell"><span>${resourceHours(resource, options.availability ?? [])}</span><a class="meta-link" href="${root}/hours?resource=${resource.id}">Set hours</a></span></td><td data-label="Resource id"><code class="muted truncate id" title="${resource.id}">${resource.id}</code></td></tr>`)}</tbody></table></div>`}`;
1470
1754
  return respond(status, layout('Resources', content, session, 'resources'));
1471
1755
  }
1472
1756
  /** The tenant's locations and groups (RFC 0003 G2, G3): what they are, and adding and changing them. */
@@ -1492,7 +1776,7 @@ ${resources.length === 0
1492
1776
  <td data-label="Location" class="strong"><span class="cell"><span class="truncate" title="${paths.get(location.ref) ?? location.name}">${location.name}</span><span class="meta">${location.ref}${parent ? ` · in ${parent}` : ''}</span></span></td>
1493
1777
  <td data-label="Time zone">${zone ? (zone.from === location ? zone.timezone : html `<span class="cell"><span>${zone.timezone}</span><span class="meta">from ${zone.from.name}</span></span>`) : html `<span class="muted">None</span>`}</td>
1494
1778
  <td data-label="Resources">${String(location.resources)}</td>
1495
- <td class="actions"><details class="act"><summary>Edit</summary><div class="pop"><form method="post" action="${root}/locations">${sent()}<input type="hidden" name="ref" value="${location.ref}">
1779
+ <td class="actions">${options.resources.hours ? html `<a class="button" href="${root}/hours?location=${location.ref}">Hours</a>` : html ``}<details class="act"><summary>Edit</summary><div class="pop"><form method="post" action="${root}/locations">${sent()}<input type="hidden" name="ref" value="${location.ref}">
1496
1780
  <label>Name<input name="name" required maxlength="${nameLimit}" value="${location.name}"></label>
1497
1781
  <label>Inside<select name="parent">${insideChoices(location.parentRef, location.ref)}</select></label>
1498
1782
  <label>Time zone<input name="timezone" maxlength="100" value="${location.timezone ?? ''}" placeholder="As where it is"></label>
@@ -1510,7 +1794,7 @@ ${resources.length === 0
1510
1794
  <button type="submit">Save group</button></form></div></details><details class="act danger"><summary>Delete</summary><div class="pop"><p>Its resources stay as they are, in no group of this name.</p>
1511
1795
  <form method="post" action="${root}/groups/delete">${sent()}<input type="hidden" name="ref" value="${group.ref}"><button class="danger" type="submit">Delete group</button></form></div></details></td>
1512
1796
  </tr>`);
1513
- const content = html `${noticeOf(notice)}
1797
+ const content = html `${noticeOf(notice, status)}
1514
1798
  <p><a class="back" href="${root}/resources">Resources</a></p>
1515
1799
  ${pageHead('Locations and groups')}
1516
1800
  <section class="panel" id="locations">
@@ -1542,32 +1826,46 @@ ${groups.length === 0
1542
1826
  </section>`;
1543
1827
  return respond(status, layout('Locations and groups', content, session, 'resources'));
1544
1828
  }
1545
- async function settingsPage(session, status = 200, notice, draft) {
1546
- const rules = options.availability ?? [];
1547
- const current = session.orgs?.find((org) => org.id === session.org);
1548
- const content = html `${noticeOf(notice)}
1549
- ${pageHead('Settings')}
1550
- ${await assistantSettingsPanel(session, draft)}
1551
- <section class="panel">
1552
- <h2>Endpoints</h2>
1553
- ${endpoints()}
1554
- </section>
1555
- <section class="panel">
1556
- <h2>Server hours</h2>
1557
- ${rules.length === 0
1558
- ? html `<p class="empty">None: agents find a free slot only on resources with hours of their own.</p>`
1559
- : html `<ul class="hours">${rules.map((rule) => html `<li>${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}</li>`)}</ul>`}
1560
- <p class="fine">For resources without hours of their own, in each resource's time zone. Set by the server's operator.</p>
1561
- </section>
1562
- <section class="panel">
1563
- <h2>Account</h2>
1564
- <dl class="facts"><dt>Signed in as</dt><dd>${shownAs(session)}</dd>${current
1565
- ? html `<dt>Organization</dt><dd>${current.login}</dd>${(session.orgs?.length ?? 0) > 1 ? html `<dt>Also a member of</dt><dd>${(session.orgs ?? []).filter((org) => org.id !== current.id).map((org) => org.login).join(', ')}</dd>` : html ``}`
1566
- : html `<dt>Workspace</dt><dd>Personal</dd>`}<dt>Session ends</dt><dd>${formatDate(new Date(session.exp))}</dd></dl>
1567
- ${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}
1568
- </section>`;
1829
+ /** Resources with hours of their own the tenant's hours page names; the rest are counted. */
1830
+ const OWN_HOURS_NAMED = 8;
1831
+ /** Settings' sections, each its own page: the assistant's form apart from what is only read. */
1832
+ const SETTINGS_SECTIONS = Object.freeze([
1833
+ { id: 'assistant', label: 'Assistant', path: '/settings' },
1834
+ { id: 'endpoints', label: 'Endpoints', path: '/settings/endpoints' },
1835
+ { id: 'hours', label: 'Hours', path: '/settings/hours' },
1836
+ { id: 'account', label: 'Account', path: '/settings/account' },
1837
+ ]);
1838
+ async function settingsPage(session, status = 200, notice, draft, section = 'assistant') {
1839
+ const store = assistantStore();
1840
+ const sections = SETTINGS_SECTIONS.filter((entry) => entry.id !== 'assistant' || store);
1841
+ const shown = section === 'assistant' && !store ? 'endpoints' : section;
1842
+ const connected = store ? Boolean(await store.getSettings(tenantOf(session))) : false;
1843
+ const nav = html `<nav class="subnav" aria-label="Settings">${sections.map((entry) => html `<a href="${root}${entry.path}"${entry.id === shown ? html ` aria-current="page"` : html ``}>${entry.label}${entry.id === 'assistant' && connected ? html `<span class="nav-dot" aria-hidden="true"></span>` : html ``}</a>`)}</nav>`;
1844
+ let body;
1845
+ if (shown === 'assistant') {
1846
+ body = await assistantSettingsSection(session, status, notice, draft);
1847
+ }
1848
+ else if (shown === 'endpoints') {
1849
+ body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">MCP</span><div class="row-value">${copyField('mcp-url', mcpUrl)}</div></div><div class="row"><span class="row-label">A2A</span><div class="row-value">${copyField('a2a-url', a2aUrl)}</div></div></div>`;
1850
+ }
1851
+ else if (shown === 'hours') {
1852
+ const rules = options.availability ?? [];
1853
+ body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Default hours</span><div class="row-value${rules.length === 0 ? ' muted' : ''}">${rules.length === 0
1854
+ ? "None. Agents find free time only on resources with hours of their own, their location's or yours."
1855
+ : html `${rules.map((rule, index) => html `${index > 0 ? html `<br>` : html ``}${daysOf(rule.rrule)}: ${hoursOf(rule.startMinutes, rule.durationMinutes)}`)}`}</div></div></div><p class="fine">In each resource's own time zone, for resources with no hours of their own, their location's or <a href="${root}/hours">yours</a>. Set by the server's operator.</p>`;
1856
+ }
1857
+ else {
1858
+ const current = session.orgs?.find((org) => org.id === session.org);
1859
+ const others = (session.orgs ?? []).filter((org) => org.id !== current?.id);
1860
+ body = html `${noticeOf(notice, status)}<div class="set-card"><div class="row"><span class="row-label">Signed in as</span><div class="row-value">${shownAs(session)}</div></div><div class="row"><span class="row-label">${current ? 'Organization' : 'Workspace'}</span><div class="row-value">${current ? current.login : 'Personal'}</div></div>${current && others.length > 0
1861
+ ? html `<div class="row"><span class="row-label">Also a member of</span><div class="row-value">${others.map((org) => org.login).join(', ')}</div></div>`
1862
+ : html ``}<div class="row"><span class="row-label">Session ends</span><div class="row-value">${formatDate(new Date(session.exp))}</div></div></div>${current ? html `<p class="fine">Membership is read from GitHub when you sign in.</p>` : html ``}`;
1863
+ }
1864
+ const content = html `${pageHead('Settings')}<div class="settings">${nav}<div class="settings-body">${body}</div></div>`;
1569
1865
  return respond(status, layout('Settings', content, session, 'settings'));
1570
1866
  }
1867
+ /** A value to copy: monospace, cut with an ellipsis, with a Copy button (app.js). */
1868
+ const copyField = (id, value) => html `<dl class="endpoints"><dd><code id="${id}">${value}</code><button class="quiet" type="button" data-copy="${id}">Copy</button></dd></dl>`;
1571
1869
  /** The tenant's resource with that id and every resource (for names), or null for anything else. */
1572
1870
  async function resourceDetailPage(session, id, status = 200, notice) {
1573
1871
  const tenantRef = tenantOf(session);
@@ -1612,11 +1910,11 @@ ${current ? html `<p class="fine">Membership is read from GitHub when you sign i
1612
1910
  const fallback = from ? `As its default (${from.value ? 'on' : 'off'}, ${from.from})` : 'Off';
1613
1911
  return html `<label>${label}<select name="${key}"><option value=""${chosen(undefined)}>${fallback}</option><option value="1"${chosen(true)}>On</option><option value="0"${chosen(false)}>Off</option></select></label>`;
1614
1912
  };
1615
- const content = html `${noticeOf(notice)}
1913
+ const content = html `${noticeOf(notice, status)}
1616
1914
  <p><a class="back" href="${root}/resources">Resources</a></p>
1617
1915
  ${pageHead(resourceName(resource, resource.id))}
1618
1916
  ${resource.description ? html `<p>${resource.description}</p>` : html ``}
1619
- <dl class="facts">${resource.name && resource.externalRef ? html `<dt>Reference</dt><dd>${resource.externalRef}</dd>` : html ``}${resource.kind ? html `<dt>Kind</dt><dd>${KIND_NAMES[resource.kind]}</dd>` : html ``}${resource.capacity !== undefined ? html `<dt>Capacity</dt><dd>${String(resource.capacity)}</dd>` : html ``}${resource.labels ? html `<dt>Labels</dt><dd>${resource.labels.join(', ')}</dd>` : html ``}${resource.location ? html `<dt>Location</dt><dd>${paths.get(resource.location) ?? resource.locationName ?? resource.location}</dd>` : html ``}${resource.groups ? html `<dt>Groups</dt><dd>${resource.groups.map((ref) => groupNames.get(ref) ?? ref).join(', ')}</dd>` : html ``}<dt>Time zone</dt><dd>${resource.timezone}</dd><dt>Bookable hours</dt><dd>${hoursSummary(resource.availabilityRules)} <a href="${root}/hours?resource=${resource.id}">Set hours</a></dd><dt>Resource id</dt><dd><code class="muted">${resource.id}</code></dd></dl>
1917
+ <dl class="facts">${resource.name && resource.externalRef ? html `<dt>Reference</dt><dd>${resource.externalRef}</dd>` : html ``}${resource.kind ? html `<dt>Kind</dt><dd>${KIND_NAMES[resource.kind]}</dd>` : html ``}${resource.capacity !== undefined ? html `<dt>Capacity</dt><dd>${String(resource.capacity)}</dd>` : html ``}${resource.labels ? html `<dt>Labels</dt><dd>${resource.labels.join(', ')}</dd>` : html ``}${resource.location ? html `<dt>Location</dt><dd>${paths.get(resource.location) ?? resource.locationName ?? resource.location}</dd>` : html ``}${resource.groups ? html `<dt>Groups</dt><dd>${resource.groups.map((ref) => groupNames.get(ref) ?? ref).join(', ')}</dd>` : html ``}<dt>Time zone</dt><dd>${resource.timezone}</dd><dt>Bookable hours</dt><dd>${resourceHours(resource, options.availability ?? [])} <a href="${root}/hours?resource=${resource.id}">Set hours</a></dd><dt>Resource id</dt><dd><code class="muted">${resource.id}</code></dd></dl>
1620
1918
  <div class="split">
1621
1919
  <div class="stack">
1622
1920
  ${options.resources.setRules
@@ -1634,7 +1932,18 @@ ${field('stepOffsetMinutes', 'Steps start past each step by', 'min')}
1634
1932
  ${field('bufferBeforeMinutes', 'Kept free before each booking', 'min')}
1635
1933
  ${field('turnaroundMinutes', 'Turnaround after each booking', 'min')}
1636
1934
  ${field('checkInMinutes', 'Release if not checked in after', 'min')}
1935
+ ${field('minDurationMinutes', 'Shortest booking', 'min')}
1936
+ ${field('maxDurationMinutes', 'Longest booking', 'min')}
1937
+ ${field('defaultDurationMinutes', 'Usual length, offered when none is asked', 'min')}
1938
+ ${field('cancelCutoffMinutes', 'Cancel up to, before the start', 'min')}
1939
+ ${field('rescheduleCutoffMinutes', 'Move up to, before the start', 'min')}
1637
1940
  </div>
1941
+ ${resource.bookingRules?.allowedDurations || resource.bookingRules?.pickupTimes || resource.bookingRules?.returnTimes || resource.bookingRules?.cancellationFee ? html `<p class="fine">Set through the API, and kept when you save: ${[
1942
+ resource.bookingRules.allowedDurations ? `lengths ${ruleValueText(resource.bookingRules.allowedDurations)} min` : '',
1943
+ resource.bookingRules.pickupTimes ? `pick-up ${ruleValueText(resource.bookingRules.pickupTimes)}` : '',
1944
+ resource.bookingRules.returnTimes ? `return ${ruleValueText(resource.bookingRules.returnTimes)}` : '',
1945
+ resource.bookingRules.cancellationFee ? `cancellation fee ${ruleValueText(resource.bookingRules.cancellationFee)}` : '',
1946
+ ].filter(Boolean).join('; ')}.</p>` : html ``}
1638
1947
  ${toggle('enforceHours', 'enforce_hours', 'Only inside bookable hours')}
1639
1948
  ${toggle('requireCertain', 'require_certain', 'Only in time its calendar syncs have read today')}
1640
1949
  ${inheritedNote(inherited)}
@@ -1743,6 +2052,12 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1743
2052
  const requireCertain = choice('require_certain');
1744
2053
  if (requireCertain !== undefined)
1745
2054
  rules.requireCertain = requireCertain;
2055
+ // Rules the form does not edit (set through the API) are kept.
2056
+ for (const name of ['allowedDurations', 'pickupTimes', 'returnTimes', 'cancellationFee']) {
2057
+ const kept = resource.bookingRules?.[name];
2058
+ if (kept !== undefined)
2059
+ rules[name] = kept;
2060
+ }
1746
2061
  try {
1747
2062
  await options.resources.setRules(tenantOf(session), id, Object.keys(rules).length === 0 ? null : rules, personOf(session));
1748
2063
  }
@@ -1960,7 +2275,7 @@ ${activity ? html `<section class="panel"><h2>Activity</h2>${activityPanel(activ
1960
2275
  <form method="post" action="${root}/webhooks/delete"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="id" value="${endpoint.id}"><button class="danger" type="submit">Delete webhook</button></form></div></details></td>
1961
2276
  </tr>`;
1962
2277
  });
1963
- const content = html `${noticeOf(notice)}
2278
+ const content = html `${noticeOf(notice, status)}
1964
2279
  ${pageHead('Webhooks')}
1965
2280
  <div class="stats">
1966
2281
  <div class="stat"><span class="stat-label">Endpoints</span><span class="stat-value">${String(endpoints.length)}</span></div>
@@ -1991,7 +2306,7 @@ ${endpoints.length === 0
1991
2306
  const created = await hooks.create(tenantOf(session), (form.get('url') ?? '').trim(), types.length === 0 ? undefined : types, personOf(session));
1992
2307
  if (!created.ok) {
1993
2308
  const message = created.code === 'webhook_limit_reached'
1994
- ? `You have ${String(created.limit)} webhooks, as many as ${options.billing ? 'your plan' : 'Slotlock'} allows. Delete one first${options.billing ? ', or see Plan' : ''}.`
2309
+ ? `You have ${String(created.limit)} webhooks, as many as ${plans ? 'your plan' : 'Slotlock'} allows. Delete one first${plans ? ', or see Usage' : ''}.`
1995
2310
  : created.code === 'invalid_webhook_url'
1996
2311
  ? 'Give an https URL, with no user name, password or #fragment.'
1997
2312
  : 'Choose events from the list.';
@@ -2060,7 +2375,7 @@ ${endpoints.length === 0
2060
2375
  });
2061
2376
  const waiting = entries.filter((entry) => entry.status === 'waiting').length;
2062
2377
  const offered = entries.filter((entry) => entry.status === 'offered').length;
2063
- const content = html `${noticeOf(notice)}
2378
+ const content = html `${noticeOf(notice, status)}
2064
2379
  ${pageHead('Waitlist')}
2065
2380
  <div class="stats">
2066
2381
  <div class="stat"><span class="stat-label">Waiting</span><span class="stat-value">${String(waiting)}</span></div>
@@ -2126,7 +2441,7 @@ ${entries.length === 0
2126
2441
  <td data-label="Decided by"><code class="truncate" title="${entry.decidedBy ?? ''}">${entry.decidedBy ?? '—'}</code></td>
2127
2442
  <td data-label="Asked">${ago(entry.createdAt, now())}</td>
2128
2443
  </tr>`);
2129
- const content = html `${noticeOf(notice)}
2444
+ const content = html `${noticeOf(notice, status)}
2130
2445
  ${pageHead('Approvals')}
2131
2446
  <p class="fine">Bookings an approval policy holds for a person. Their time stays held until you decide or the wait ends. Policies are set through the API (slotlock_put_approval_policy).</p>
2132
2447
  <div class="stats">
@@ -2177,7 +2492,35 @@ ${decided.length === 0
2177
2492
  const OWN_HOST_REFUSAL = 'That URL is this Slotlock deployment’s own address. Give the address of the model’s API.';
2178
2493
  const assistantTimeout = assistantOptions?.timeoutMs ?? 30_000;
2179
2494
  const assistantTurnTimeout = assistantOptions?.turnTimeoutMs ?? 90_000;
2180
- const conversationTtl = assistantOptions?.conversationTtlMs ?? 2 * 3_600_000;
2495
+ // Kept until the member deletes a conversation, unless the deployment gives them a lifetime.
2496
+ const conversationTtl = assistantOptions?.conversationTtlMs ?? null;
2497
+ /** How recently a conversation was used for the assistant page to open on it rather than a new one. */
2498
+ const CHAT_RESUME_MS = 2 * 3_600_000;
2499
+ const newChatId = () => randomBytes(12).toString('base64url');
2500
+ /** The conversation a page or form names, or null for a new one. */
2501
+ const chatOf = (value) => value && SLOTLOCK_ASSISTANT_CHAT_ID.test(value) ? value : null;
2502
+ const chatUrl = (id) => `${root}/assistant?c=${id ?? 'new'}`;
2503
+ /**
2504
+ * The conversation a form goes to: the one its `chat` names, a new one when it is empty, and with
2505
+ * no `chat` at all (a page of an earlier release), the one the page would open: the member's most
2506
+ * recent, used in the past two hours (`anyAge`: however long ago).
2507
+ */
2508
+ async function chatOfForm(store, session, form, anyAge = false) {
2509
+ if (form.has('chat'))
2510
+ return chatOf(form.get('chat'));
2511
+ const [latest] = await store.listChats(tenantOf(session), personOf(session), 1);
2512
+ return latest && (anyAge || now() - latest.updatedAt.getTime() < CHAT_RESUME_MS) ? latest.id : null;
2513
+ }
2514
+ /** A conversation's first title: its first message on one line, cut at a word near 60 characters. */
2515
+ const chatTitle = (text) => {
2516
+ const line = text.replace(/\s+/gu, ' ').trim();
2517
+ const chars = [...line];
2518
+ if (chars.length <= 60)
2519
+ return line || 'New chat';
2520
+ const cut = chars.slice(0, 60).join('');
2521
+ const space = cut.lastIndexOf(' ');
2522
+ return `${(space > 30 ? cut.slice(0, space) : cut).replace(/[\s.,;:!?-]+$/u, '')}…`;
2523
+ };
2181
2524
  if (assistantOptions && Buffer.byteLength(assistantOptions.secret, 'utf8') < 32) {
2182
2525
  throw new Error('Slotlock dashboard assistant secret must be at least 32 bytes');
2183
2526
  }
@@ -2367,38 +2710,127 @@ ${decided.length === 0
2367
2710
  model: { provider: settings.provider, baseUrl: settings.baseUrl, model: settings.model, apiKey },
2368
2711
  };
2369
2712
  }
2713
+ /** When a change happens, in its resource's time zone when the card knows it, else UTC. */
2714
+ function whenOf(start, end, zone) {
2715
+ if (typeof start !== 'string')
2716
+ return null;
2717
+ const from = new Date(start);
2718
+ if (Number.isNaN(from.getTime()))
2719
+ return null;
2720
+ const timeZone = zone ?? 'UTC';
2721
+ let day;
2722
+ let time;
2723
+ try {
2724
+ day = new Intl.DateTimeFormat('en-GB', { weekday: 'short', day: 'numeric', month: 'short', timeZone });
2725
+ time = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone });
2726
+ }
2727
+ catch {
2728
+ return null;
2729
+ }
2730
+ const to = typeof end === 'string' ? new Date(end) : null;
2731
+ const span = to && !Number.isNaN(to.getTime())
2732
+ ? day.format(from) === day.format(to)
2733
+ ? `${time.format(from)}–${time.format(to)}`
2734
+ : `${time.format(from)} – ${day.format(to)}, ${time.format(to)}`
2735
+ : time.format(from);
2736
+ return `${day.format(from)}, ${span} ${timeZone}`;
2737
+ }
2738
+ /** What a proposed change's fields are called on its card. */
2739
+ const FACT_NAMES = Object.freeze({
2740
+ resource_id: 'Resource',
2741
+ resource_ids: 'Resources',
2742
+ pool_id: 'Pool',
2743
+ title: 'Title',
2744
+ external_ref: 'Reference',
2745
+ event_id: 'Booking',
2746
+ hold_id: 'Hold',
2747
+ hold_group_id: 'Held together',
2748
+ ttl_seconds: 'Held for',
2749
+ notes: 'Note',
2750
+ description: 'Note',
2751
+ metadata: 'Details',
2752
+ slots: 'Times',
2753
+ });
2370
2754
  /**
2371
- * A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the
2372
- * proposal is bounded instead). A resource is named, with its id beside the name.
2755
+ * A field of a proposed change, as the card shows it: all of it, wrapped, never cut (the proposal
2756
+ * is bounded instead). A resource is named; its id is the name's title.
2373
2757
  */
2374
2758
  function fieldValue(name, value, names) {
2375
- const named = (id) => (names.has(id) ? `${names.get(id)} (${id})` : id);
2376
- if (typeof value === 'string') {
2377
- return html `<span class="wrap">${name === 'resource_id' ? named(value) : value}</span>`;
2378
- }
2759
+ const named = (id) => names.has(id) ? html `<span title="${id}">${names.get(id)}</span>` : html `<code>${id}</code>`;
2760
+ if (name === 'resource_id' && typeof value === 'string')
2761
+ return named(value);
2762
+ if (name === 'resource_ids' && Array.isArray(value) && value.every((item) => typeof item === 'string')) {
2763
+ return html `${value.map((id, index) => html `${index > 0 ? ', ' : ''}${named(id)}`)}`;
2764
+ }
2765
+ if (name === 'ttl_seconds' && typeof value === 'number') {
2766
+ return html `${value >= 60 ? `${Math.round(value / 60)} min` : `${value} s`}`;
2767
+ }
2768
+ if ((name === 'event_id' || name === 'hold_id' || name === 'hold_group_id') && typeof value === 'string')
2769
+ return html `<code>${value}</code>`;
2770
+ if (typeof value === 'string')
2771
+ return html `<span class="wrap">${value}</span>`;
2379
2772
  if (Array.isArray(value) && value.every((item) => typeof item === 'string')) {
2380
- return html `<span class="wrap">${value.map((item) => (name === 'resource_ids' ? named(item) : item)).join(', ')}</span>`;
2773
+ return html `<span class="wrap">${value.join(', ')}</span>`;
2381
2774
  }
2382
- if (typeof value === 'number' || typeof value === 'boolean' || value === null) {
2775
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null)
2383
2776
  return html `${String(value)}`;
2384
- }
2385
- return html `<pre class="wrap">${JSON.stringify(value, null, 2)}</pre>`;
2777
+ return html `<pre>${JSON.stringify(value, null, 2)}</pre>`;
2386
2778
  }
2387
- function actionCard(session, action, names) {
2779
+ /** Why a change or a step was refused, in words, for the codes a member meets. */
2780
+ const REFUSALS = Object.freeze({
2781
+ overlap: 'that time was just taken',
2782
+ pool_full: 'the pool is full then',
2783
+ not_found: 'it no longer exists',
2784
+ resource_not_found: 'the resource no longer exists',
2785
+ event_not_found: 'the booking no longer exists',
2786
+ invalid_input: 'the request was incomplete',
2787
+ invalid_arguments: 'the request was incomplete',
2788
+ validation_error: 'the request was incomplete',
2789
+ turn_read_budget_exceeded: 'one message may read only so much',
2790
+ too_many_pending_changes: 'too many changes are waiting for you',
2791
+ change_too_large_to_show: 'the change was too large to show',
2792
+ tool_not_available: 'the assistant may not do that',
2793
+ too_many_calls: 'too many at once',
2794
+ quota_exceeded: "this month's allowance is used up",
2795
+ approval_pending: 'it is waiting for an approval',
2796
+ });
2797
+ const refusal = (code) => REFUSALS[code] ?? code.replaceAll('_', ' ');
2798
+ function actionCard(session, action, names, zones, chatId) {
2799
+ const verb = slotlockAssistantActionVerb(action.operation);
2800
+ const title = action.summary || `${verb.verb}: ${toolTitles.get(action.operation) ?? action.operation}`;
2801
+ if (action.status !== 'pending') {
2802
+ const [tone, word] = action.status === 'done'
2803
+ ? ['ok', verb.done]
2804
+ : action.status === 'dismissed'
2805
+ ? ['', 'Declined']
2806
+ : action.status === 'approved'
2807
+ ? ['', 'Running…']
2808
+ : ['bad', `Refused: ${refusal(action.outcome ?? 'failed')}`];
2809
+ return html `<li class="turn action" id="action-${action.id}"><p class="receipt ${tone}"><span>${title}</span><span class="why">${word}</span></p></li>`;
2810
+ }
2388
2811
  const token = csrfToken(session);
2389
- const fields = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
2390
- const summary = action.summary ? html `<p class="action-summary">${action.summary}</p>` : html ``;
2391
- const state = {
2392
- pending: { text: 'Waiting for you', tone: 'held' },
2393
- approved: { text: 'Running', tone: 'held' },
2394
- dismissed: { text: 'Dismissed', tone: 'revoked' },
2395
- done: { text: 'Done', tone: 'active' },
2396
- failed: { text: 'Failed', tone: 'revoked' },
2397
- }[action.status] ?? { text: action.status, tone: 'revoked' };
2398
- const buttons = action.status === 'pending'
2399
- ? html `<div class="action-buttons"><form class="inline" method="post" action="${root}/assistant/approve"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${action.id}"><button class="primary" type="submit">Approve</button></form><form class="inline" method="post" action="${root}/assistant/dismiss"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="action" value="${action.id}"><button type="submit">Dismiss</button></form></div>`
2400
- : html ``;
2401
- return html `<li class="action-card" id="action-${action.id}"><div class="action-head"><span class="title">${toolTitles.get(action.operation) ?? action.operation}</span><span class="pill ${state.tone}">${state.text}</span></div>${summary}<dl class="facts">${fields.map(([name, value]) => html `<dt>${name.replaceAll('_', ' ')}</dt><dd>${fieldValue(name, value, names)}</dd>`)}</dl>${action.status === 'failed' && action.outcome ? html `<p class="fine">Refused: <code>${action.outcome}</code></p>` : html ``}${buttons}</li>`;
2812
+ const input = Object.entries(action.input).filter(([name]) => name !== 'idempotency_key');
2813
+ // The change's own time zone when it gives one, else its resource's.
2814
+ const zone = typeof action.input.timezone === 'string'
2815
+ ? action.input.timezone
2816
+ : typeof action.input.resource_id === 'string'
2817
+ ? zones.get(action.input.resource_id)
2818
+ : undefined;
2819
+ const when = whenOf(action.input.starts_at, action.input.ends_at, zone);
2820
+ const facts = [];
2821
+ const more = [];
2822
+ if (when)
2823
+ facts.push(html `<dt>When</dt><dd>${when}</dd>`);
2824
+ for (const [name, value] of input) {
2825
+ if (when && (name === 'starts_at' || name === 'ends_at' || name === 'timezone'))
2826
+ continue;
2827
+ const label = FACT_NAMES[name] ?? name.charAt(0).toUpperCase() + name.slice(1).replaceAll('_', ' ');
2828
+ (name in FACT_NAMES || name === 'attendees' ? facts : more).push(html `<dt>${label}</dt><dd>${fieldValue(name, value, names)}</dd>`);
2829
+ }
2830
+ const hidden = (id) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="chat" value="${chatId ?? ''}"><input type="hidden" name="action" value="${id}">`;
2831
+ return html `<li class="turn action" id="action-${action.id}"><div class="change"><p class="change-title">${title}</p>${facts.length > 0 ? html `<dl class="facts">${facts}</dl>` : html ``}${more.length > 0
2832
+ ? html `<details class="more"><summary>All details</summary><dl class="facts">${more}</dl></details>`
2833
+ : html ``}<div class="change-buttons"><form method="post" action="${root}/assistant/approve">${hidden(action.id)}<button class="${verb.danger ? 'danger' : 'primary'}" type="submit" data-busy="${verb.busy}">${verb.verb}</button></form><form method="post" action="${root}/assistant/dismiss">${hidden(action.id)}<button class="ghost" type="submit">Decline</button></form></div></div></li>`;
2402
2834
  }
2403
2835
  function readAction(id, value) {
2404
2836
  if (!isRecord(value) || typeof value.operation !== 'string')
@@ -2412,100 +2844,282 @@ ${decided.length === 0
2412
2844
  ...(typeof value.outcome === 'string' ? { outcome: value.outcome } : {}),
2413
2845
  };
2414
2846
  }
2415
- async function assistantPage(session, status = 200, notice) {
2416
- const store = assistantStore();
2417
- if (!store)
2418
- return notFound(session);
2419
- const tenantRef = tenantOf(session);
2420
- const [settings, conversation] = await Promise.all([
2421
- store.getSettings(tenantRef),
2422
- store.getConversation(tenantRef, personOf(session)),
2423
- ]);
2424
- const entries = readSlotlockAssistantEntries(conversation?.messages);
2425
- const actions = conversation?.actions ?? {};
2426
- const names = new Map();
2427
- if (Object.keys(actions).length > 0) {
2428
- for (const resource of await options.resources.list(tenantRef)) {
2429
- names.set(resource.id, resourceName(resource, resource.id));
2430
- }
2847
+ /** The code a tool result was refused with, from the JSON the model was given. */
2848
+ function refusedCode(content) {
2849
+ try {
2850
+ const parsed = JSON.parse(content);
2851
+ const error = isRecord(parsed) && isRecord(parsed.error) ? parsed.error : null;
2852
+ return error && typeof error.code === 'string' ? error.code : null;
2431
2853
  }
2432
- const token = csrfToken(session);
2854
+ catch {
2855
+ return null;
2856
+ }
2857
+ }
2858
+ /** Notes the turn ends with that are not a failure: a member's Stop, and the step bound. */
2859
+ const quietNote = (text) => text === 'Stopped.' || text.startsWith('The assistant stopped after');
2860
+ /**
2861
+ * The conversation as the thread shows it: the member's messages as bubbles, the replies as
2862
+ * rendered Markdown, each run of tool steps folded into one line, proposed changes as cards, and
2863
+ * a failure that ends it with a way to send the last message again.
2864
+ */
2865
+ function assistantItems(session, entries, actions, names, zones, chatId) {
2433
2866
  const items = [];
2434
- for (const entry of entries) {
2435
- if (entry.role === 'user')
2436
- items.push(html `<li class="msg mine"><p>${entry.text}</p></li>`);
2437
- else if (entry.role === 'assistant' && entry.text.trim() !== '') {
2438
- items.push(html `<li class="msg bot"><p>${entry.text}</p></li>`);
2867
+ let steps = [];
2868
+ let cards = [];
2869
+ const flush = () => {
2870
+ if (steps.length > 0) {
2871
+ const bad = steps.some((step) => !step.ok);
2872
+ const seen = [];
2873
+ for (const step of steps)
2874
+ if (!seen.includes(step.label))
2875
+ seen.push(step.label);
2876
+ const summary = seen.map((label, index) => (index === 0 ? label : label.charAt(0).toLowerCase() + label.slice(1))).join(', ');
2877
+ items.push(html `<li class="turn steps"><details class="steps${bad ? ' has-bad' : ''}"><summary>${summary}</summary><ul class="step-list">${steps.map((step) => html `<li class="step ${step.ok ? 'ok' : 'bad'}">${step.label}${step.why ? html ` <span class="why">${step.why}</span>` : html ``}</li>`)}</ul></details></li>`);
2878
+ steps = [];
2879
+ }
2880
+ items.push(...cards);
2881
+ cards = [];
2882
+ };
2883
+ const lastUser = [...entries].reverse().find((entry) => entry.role === 'user');
2884
+ for (const [index, entry] of entries.entries()) {
2885
+ if (entry.role === 'user') {
2886
+ flush();
2887
+ items.push(html `<li class="turn mine"><div class="bubble">${entry.text}</div></li>`);
2888
+ }
2889
+ else if (entry.role === 'assistant') {
2890
+ if (entry.text.trim() === '')
2891
+ continue;
2892
+ flush();
2893
+ items.push(html `<li class="turn bot"><div class="prose">${new Html(renderSlotlockMarkdown(entry.text))}</div></li>`);
2439
2894
  }
2440
- else if (entry.role === 'note')
2441
- items.push(html `<li class="notice">${entry.text}</li>`);
2442
2895
  else if (entry.role === 'tool') {
2443
2896
  for (const result of entry.results) {
2897
+ const write = SLOTLOCK_ASSISTANT_TOOL_USE[result.name] === 'propose';
2444
2898
  const action = result.action ? readAction(result.action, actions[result.action]) : null;
2445
- if (action)
2446
- items.push(actionCard(session, action, names));
2447
- else {
2448
- items.push(html `<li class="tool-line">${result.error ? 'Could not run' : 'Read'}: ${toolTitles.get(result.name) ?? result.name}</li>`);
2899
+ if (action) {
2900
+ cards.push(actionCard(session, action, names, zones, chatId));
2901
+ continue;
2449
2902
  }
2903
+ const title = toolTitles.get(result.name) ?? result.name;
2904
+ const code = result.error ? refusedCode(result.content) : null;
2905
+ steps.push({
2906
+ label: slotlockAssistantStepLabel(result.name, title, result.error ? 'bad' : 'ok', write),
2907
+ ok: !result.error,
2908
+ why: code ? refusal(code) : null,
2909
+ });
2910
+ }
2911
+ }
2912
+ else if (entry.role === 'note') {
2913
+ flush();
2914
+ const last = index === entries.length - 1;
2915
+ if (last && !quietNote(entry.text) && lastUser?.role === 'user') {
2916
+ items.push(html `<li class="turn error" role="alert"><span>${entry.text}</span><form method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${csrfToken(session)}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="chat" value="${chatId ?? ''}"><input type="hidden" name="message" value="${lastUser.text}"><button class="text-button" type="submit">Retry</button></form></li>`);
2917
+ }
2918
+ else {
2919
+ items.push(html `<li class="turn note">${entry.text}</li>`);
2450
2920
  }
2451
2921
  }
2452
2922
  }
2453
- const composer = html `<form class="composer" method="post" action="${root}/assistant"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="3" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="Find a free van tomorrow morning and book it for Ana"></textarea><button class="primary" type="submit">Send</button></form>`;
2454
- const reset = entries.length > 0
2455
- ? html `<form class="inline" method="post" action="${root}/assistant/reset"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">New conversation</button></form>`
2456
- : undefined;
2457
- const content = html `${noticeOf(notice)}
2458
- ${pageHead('Assistant', reset)}
2459
- ${settings
2460
- ? html `<section class="panel chat">${items.length === 0
2461
- ? html `<p class="empty">Ask about bookings and free time. Changes wait for your approval.</p>`
2462
- : html `<ol class="thread">${items}</ol>`}<span id="latest"></span>${composer}</section>`
2463
- : html `<p class="empty">${isAdmin(session)
2464
- ? html `No model is connected. <a href="${root}/settings#assistant">Connect one in Settings</a>.`
2465
- : 'No model is connected. An admin of this organization can connect one in Settings.'}</p>`}`;
2923
+ flush();
2924
+ return items;
2925
+ }
2926
+ /** The tenant's resources as a proposed change's card names them, and each one's time zone. */
2927
+ async function resourceLookups(tenantRef) {
2928
+ const resources = await options.resources.list(tenantRef);
2929
+ return {
2930
+ resources,
2931
+ names: new Map(resources.map((resource) => [resource.id, resourceName(resource, resource.id)])),
2932
+ zones: new Map(resources.flatMap((resource) => (resource.timezone ? [[resource.id, resource.timezone]] : []))),
2933
+ };
2934
+ }
2935
+ const ASSISTANT_ICONS = {
2936
+ send: new Html('<svg class="arrow" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 13V3M3.5 7.5 8 3l4.5 4.5"/></svg>'),
2937
+ stop: new Html('<svg class="stop" viewBox="0 0 16 16" aria-hidden="true"><rect x="4" y="4" width="8" height="8" rx="1.5" fill="currentColor"/></svg>'),
2938
+ down: new Html('<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M8 3v10M3.5 8.5 8 13l4.5-4.5"/></svg>'),
2939
+ plus: new Html('<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" aria-hidden="true"><path d="M8 3v10M3 8h10"/></svg>'),
2940
+ more: new Html('<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><circle cx="3.5" cy="8" r="1.3"/><circle cx="8" cy="8" r="1.3"/><circle cx="12.5" cy="8" r="1.3"/></svg>'),
2941
+ chats: new Html('<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h6"/></svg>'),
2942
+ };
2943
+ /** How long ago a chat was used, as briefly as the list has room for: 4m, 3h, 5d, then the date. */
2944
+ function chatSince(at) {
2945
+ const minutes = Math.max(0, Math.floor((now() - at.getTime()) / 60_000));
2946
+ if (minutes < 1)
2947
+ return 'now';
2948
+ if (minutes < 60)
2949
+ return `${String(minutes)}m`;
2950
+ if (minutes < 24 * 60)
2951
+ return `${String(Math.floor(minutes / 60))}h`;
2952
+ if (minutes < 7 * 24 * 60)
2953
+ return `${String(Math.floor(minutes / (24 * 60)))}d`;
2954
+ return zoned('UTC', { day: 'numeric', month: 'short' }).format(at);
2955
+ }
2956
+ /** When a conversation was last used, as the Chats list groups them: by day, in UTC. */
2957
+ function chatGroup(at) {
2958
+ const day = (ms) => Math.floor(ms / DAY_MS);
2959
+ const daysAgo = day(now()) - day(at.getTime());
2960
+ if (daysAgo <= 0)
2961
+ return 'Today';
2962
+ if (daysAgo === 1)
2963
+ return 'Yesterday';
2964
+ if (daysAgo < 7)
2965
+ return 'Previous 7 days';
2966
+ if (daysAgo < 30)
2967
+ return 'Previous 30 days';
2968
+ return 'Older';
2969
+ }
2970
+ /**
2971
+ * A member's conversations, the most recent first, grouped by when they were last used: each
2972
+ * opens with a link, and has Rename and Delete behind its menu. `current` is the one shown.
2973
+ */
2974
+ function chatsPanel(session, chats, current) {
2975
+ const token = csrfToken(session);
2976
+ const hidden = (id) => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="chat" value="${id}">`;
2977
+ const groups = [];
2978
+ for (const chat of chats) {
2979
+ const label = chatGroup(chat.updatedAt);
2980
+ let group = groups.at(-1);
2981
+ if (!group || group.label !== label) {
2982
+ group = { label, rows: [] };
2983
+ groups.push(group);
2984
+ }
2985
+ const here = chat.id === current;
2986
+ group.rows.push(html `<li class="chat-row${here ? ' current' : ''}"><a class="chat-link" href="${chatUrl(chat.id)}"${here ? html ` aria-current="page"` : html ``}><span class="chat-title">${chat.title}</span><time class="chat-when" datetime="${chat.updatedAt.toISOString()}" title="${formatDate(chat.updatedAt)}">${chatSince(chat.updatedAt)}</time></a><details class="chat-menu"><summary aria-label="Options for ${chat.title}">${ASSISTANT_ICONS.more}</summary><div class="pop"><form class="chat-rename" method="post" action="${root}/assistant/rename">${hidden(chat.id)}<label class="visually-hidden" for="title-${chat.id}">Title</label><input id="title-${chat.id}" name="title" value="${chat.title}" maxlength="120" required><button type="submit">Rename</button></form><form method="post" action="${root}/assistant/delete">${hidden(chat.id)}<button class="danger" type="submit">Delete chat</button></form></div></details></li>`);
2987
+ }
2988
+ const list = groups.length === 0
2989
+ ? html `<p class="empty chats-empty">No chats yet.</p>`
2990
+ : html `${groups.map((group) => html `<h3 class="chat-group">${group.label}</h3><ul class="chat-rows">${group.rows}</ul>`)}`;
2991
+ return html `<aside class="chats" aria-label="Chats"><div class="chats-head"><h2>Chats</h2><a class="icon-button" href="${chatUrl(null)}" aria-label="New chat" title="New chat">${ASSISTANT_ICONS.plus}</a></div><label class="visually-hidden" for="chats-filter">Search chats</label><input id="chats-filter" class="chats-filter" type="search" placeholder="Search chats" autocomplete="off" hidden><nav class="chat-list" aria-label="Your chats">${list}</nav></aside>`;
2992
+ }
2993
+ /**
2994
+ * The assistant: a conversation, with the member's others beside it. `requested` is the page's
2995
+ * `c`: a conversation's id, `new` for a new one, or nothing for the last one used in the past two
2996
+ * hours (else a new one).
2997
+ */
2998
+ async function assistantPage(session, status = 200, notice, requested) {
2999
+ const store = assistantStore();
3000
+ if (!store)
3001
+ return notFound(session);
3002
+ const tenantRef = tenantOf(session);
3003
+ const member = personOf(session);
3004
+ const [settings, chats] = await Promise.all([store.getSettings(tenantRef), store.listChats(tenantRef, member)]);
3005
+ if (!settings) {
3006
+ const content = html `${noticeOf(notice, status)}${pageHead('Assistant')}${emptyState(ICONS.assistant, isAdmin(session) ? 'Connect a model to use the assistant.' : 'An admin of this organization connects a model in Settings.', isAdmin(session) ? { href: `${root}/settings#assistant`, label: 'Connect a model' } : undefined)}`;
3007
+ return respond(status, layout('Assistant', content, session, 'assistant'));
3008
+ }
3009
+ let chatId = chatOf(requested);
3010
+ if (requested === undefined) {
3011
+ const latest = chats[0];
3012
+ if (latest && now() - latest.updatedAt.getTime() < CHAT_RESUME_MS)
3013
+ chatId = latest.id;
3014
+ }
3015
+ const conversation = chatId ? await store.getChat(tenantRef, member, chatId) : null;
3016
+ if (chatId && !conversation) {
3017
+ // Deleted, or never this member's: a new one instead, saying so.
3018
+ chatId = null;
3019
+ if (requested !== undefined && notice === undefined) {
3020
+ notice = 'That chat is not here: it was deleted.';
3021
+ status = 404;
3022
+ }
3023
+ }
3024
+ const entries = readSlotlockAssistantEntries(conversation?.messages);
3025
+ const actions = conversation?.actions ?? {};
3026
+ const empty = entries.length === 0;
3027
+ const { resources, names, zones } = empty || Object.keys(actions).length > 0
3028
+ ? await resourceLookups(tenantRef)
3029
+ : { resources: [], names: new Map(), zones: new Map() };
3030
+ const token = csrfToken(session);
3031
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="chat" value="${chatId ?? ''}">`;
3032
+ const first = resources.find((resource) => !resource.archivedAt);
3033
+ const named = first ? resourceName(first, first.id) : null;
3034
+ // A read chip asks at once; a write chip fills the box to finish (app.js), or asks (no script).
3035
+ const prompts = [
3036
+ { label: "What's on today", message: "What's booked today?" },
3037
+ { label: 'Find free time', message: named ? `When is ${named} free tomorrow morning?` : 'What is free tomorrow morning?' },
3038
+ { label: 'Book', message: named ? `Book ${named} tomorrow 9:00–10:00` : 'Book ', fill: true },
3039
+ { label: 'Move a booking', message: 'Move the next booking one hour later', fill: true },
3040
+ { label: 'Cancel a booking', message: 'Cancel the next booking', fill: true },
3041
+ ];
3042
+ const chips = html `<div class="assistant-start"><div class="chips">${prompts.map((prompt) => html `<form method="post" action="${root}/assistant">${hidden()}<button class="chip" type="submit" name="message" value="${prompt.message}"${prompt.fill ? html ` data-fill="${prompt.message}"` : html ``}>${prompt.label}</button></form>`)}</div></div>`;
3043
+ const composer = html `<div class="dock"><button class="jump" type="button" hidden aria-label="Jump to the latest message">${ASSISTANT_ICONS.down}</button><form class="composer" method="post" action="${root}/assistant">${hidden()}<label class="visually-hidden" for="message">Message</label><textarea id="message" name="message" rows="2" maxlength="${String(SLOTLOCK_ASSISTANT_MAX_MESSAGE)}" required placeholder="${empty ? 'Ask about bookings' : 'Reply…'}"></textarea><div class="composer-bar"><span class="count" hidden></span><button class="send" type="submit" aria-label="Send">${ASSISTANT_ICONS.send}${ASSISTANT_ICONS.stop}</button></div></form></div>`;
3044
+ const actionsHead = html `<div class="assistant-actions"><a class="button ghost chats-link" href="${root}/assistant/chats">${ASSISTANT_ICONS.chats}Chats</a><a class="button ghost new-chat" href="${chatUrl(null)}">${ASSISTANT_ICONS.plus}New chat</a></div>`;
3045
+ const flag = empty ? new Html(' data-empty') : html ``;
3046
+ const content = html `${noticeOf(notice, status)}<div class="assistant-shell">${chatsPanel(session, chats, chatId)}<div class="assistant-page"${flag}>${pageHead(conversation?.title ?? 'Assistant', actionsHead)}<div class="assistant" data-assistant data-stream="${root}/assistant/stream" data-page="${root}/assistant"${flag}><ol class="thread">${assistantItems(session, entries, actions, names, zones, chatId)}</ol><span id="latest"></span>${chips}${composer}</div></div></div>`;
2466
3047
  return respond(status, layout('Assistant', content, session, 'assistant'));
2467
3048
  }
2468
- async function assistantSettingsPanel(session, draft) {
3049
+ /** The member's conversations on a page of their own: the Chats list where the page is narrow. */
3050
+ async function chatsPage(session) {
2469
3051
  const store = assistantStore();
2470
3052
  if (!store)
2471
- return html ``;
3053
+ return notFound(session);
3054
+ const chats = await store.listChats(tenantOf(session), personOf(session));
3055
+ const content = html `<div class="chats-page">${pageHead('Chats', html `<a class="button ghost" href="${chatUrl(null)}">${ASSISTANT_ICONS.plus}New chat</a>`)}${chatsPanel(session, chats, null)}</div>`;
3056
+ return respond(200, layout('Chats', content, session, 'assistant'));
3057
+ }
3058
+ /** The named provider a provider and base URL are, or none (Custom). */
3059
+ const presetOf = (provider, baseUrl) => SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id !== 'custom' && preset.provider === provider && preset.baseUrl === baseUrl);
3060
+ /**
3061
+ * The assistant's settings: what is connected and whether it answers (Test, its result in place),
3062
+ * then the form, one provider chosen from tiles, and Disconnect apart. A member sees only what is
3063
+ * connected, and where their messages go.
3064
+ */
3065
+ async function assistantSettingsSection(session, status, notice, draft) {
3066
+ const store = assistantStore();
2472
3067
  const settings = await store.getSettings(tenantOf(session));
2473
- const sentTo = html `<p class="fine">Members' messages, and the bookings and resources the assistant reads for them, are sent to this provider.</p>`;
2474
- if (!isAdmin(session)) {
2475
- return html `<section class="panel" id="assistant"><h2>Assistant</h2>${settings
2476
- ? html `<dl class="facts"><dt>Provider</dt><dd>${settings.provider === 'anthropic' ? 'Anthropic' : 'OpenAI-compatible'}</dd><dt>Model</dt><dd>${settings.model}</dd></dl>${sentTo}`
2477
- : html `<p class="empty">No model is connected. An admin can connect one.</p>`}</section>`;
2478
- }
2479
3068
  const token = csrfToken(session);
3069
+ const hidden = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
3070
+ const savedName = settings ? (presetOf(settings.provider, settings.baseUrl)?.name ?? 'Custom') : null;
3071
+ // Where messages go, in words: the provider's name, or a custom one's host.
3072
+ const hostOf = (url) => {
3073
+ try {
3074
+ return new URL(url).host;
3075
+ }
3076
+ catch {
3077
+ return url;
3078
+ }
3079
+ };
3080
+ const savedTo = settings ? (savedName === 'Custom' ? hostOf(settings.baseUrl) : savedName) : null;
3081
+ const admin = isAdmin(session);
3082
+ const badge = !settings
3083
+ ? html `<span class="badge">Not set up</span>`
3084
+ : notice && status === 200 && notice.startsWith('Connected')
3085
+ ? html `<span class="badge ok">Connected</span>`
3086
+ : notice && status === 502
3087
+ ? html `<span class="badge bad">Failing</span>`
3088
+ : html `<span class="badge ok">Set up</span>`;
3089
+ const test = settings && admin
3090
+ ? html `<form method="post" action="${root}/settings/assistant/test">${hidden()}<button type="submit">Test</button></form>`
3091
+ : html ``;
3092
+ const statusCard = html `<div class="set-card status-card" id="assistant"><div class="status-head"><span class="mono-tile" aria-hidden="true">${savedName ? savedName.charAt(0) : '–'}</span><div class="status-name"><strong>${savedName ?? 'No model connected'}</strong>${settings ? html `<code>${settings.model}</code>` : html ``}</div>${badge}${test}</div>${noticeOf(notice, status)}</div>`;
3093
+ const sentTo = (name) => html `<p class="fine">Members' messages, and the bookings they ask about, are sent to ${name ?? 'the provider you choose'}.</p>`;
3094
+ if (!admin) {
3095
+ return html `${statusCard}${settings ? sentTo(savedTo) : html `<p class="fine">An admin of this organization connects a model here.</p>`}`;
3096
+ }
2480
3097
  // What the form shows: a draft (a preset, or a model list) over what is saved.
2481
- const provider = draft?.provider ?? settings?.provider;
3098
+ const provider = draft?.provider ?? settings?.provider ?? 'anthropic';
2482
3099
  const baseUrl = draft ? draft.baseUrl : (settings?.baseUrl ?? '');
2483
3100
  const model = draft?.model ?? settings?.model ?? '';
2484
- const selected = (value) => (provider === value ? html ` selected` : html ``);
3101
+ const chosen = settings || draft ? (presetOf(provider, baseUrl)?.id ?? 'custom') : 'anthropic';
3102
+ const chosenName = chosen === 'custom' ? (baseUrl ? hostOf(baseUrl) : null) : (SLOTLOCK_ASSISTANT_PRESETS.find((preset) => preset.id === chosen)?.name ?? null);
2485
3103
  const models = draft?.models ?? [];
2486
- return html `<section class="panel" id="assistant">
2487
- <h2>Assistant</h2>
2488
- <form class="presets" method="get" action="${root}/settings"><span class="fine">Fill in for</span>${SLOTLOCK_ASSISTANT_PRESETS.map((preset) => html `<button type="submit" name="preset" value="${preset.id}">${preset.name}</button>`)}</form>
2489
- <form method="post" action="${root}/settings/assistant">
2490
- <input type="hidden" name="csrf" value="${token}">
2491
- <input type="hidden" name="once" value="${formNonce(session)}">
2492
- <div class="grid-form">
2493
- <label>Provider<select name="provider"><option value="anthropic"${selected('anthropic')}>Anthropic Messages API</option><option value="openai"${selected('openai')}>OpenAI-compatible</option></select></label>
2494
- <label>Base URL<input name="base_url" required maxlength="2048" value="${baseUrl}" placeholder="https://api.anthropic.com"></label>
2495
- <label>Model<input name="model" required maxlength="200" value="${model}" placeholder="your-model"${models.length > 0 ? html ` list="assistant-models"` : html ``}></label>${models.length > 0
3104
+ const tiles = html `<div class="tiles" role="radiogroup" aria-label="Provider">${SLOTLOCK_ASSISTANT_PRESETS.map((preset) => html `<label class="tile"><input type="radio" name="preset" value="${preset.id}"${preset.id === chosen ? html ` checked` : html ``}><span class="tile-mark" aria-hidden="true">${preset.name.charAt(0)}</span>${preset.name}</label>`)}</div>`;
3105
+ const format = (value, label) => html `<label><input type="radio" name="provider" value="${value}"${provider === value ? html ` checked` : html ``}>${label}</label>`;
3106
+ const keyField = settings?.keyHint
3107
+ ? html `<div class="key-set"><code>•••• ${settings.keyHint}</code><details><summary>Replace</summary><input id="api-key" name="api_key" type="password" autocomplete="off" maxlength="4096" aria-label="New API key"></details></div>`
3108
+ : html `<input id="api-key" name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="Optional for a local model">`;
3109
+ const form = html `<form class="set-card provider-form" method="post" action="${root}/settings/assistant">${hidden()}<div class="row stack"><span class="row-label">Provider</span>${tiles}</div><div class="row custom-only"><span class="row-label">API format</span><div class="segmented">${format('anthropic', 'Anthropic Messages')}${format('openai', 'OpenAI-compatible')}</div></div><div class="row custom-only"><label class="row-label" for="base-url">Base URL</label><input id="base-url" class="mono" name="base_url" maxlength="2048" value="${baseUrl}" placeholder="https://host/v1"></div><div class="row"><label class="row-label" for="model">Model</label><div class="field-with-button"><input id="model" class="mono" name="model" required maxlength="200" value="${model}" placeholder="Model name"${models.length > 0 ? html ` list="assistant-models"` : html ``}><button type="submit" formaction="${root}/settings/assistant/models" formnovalidate>Load models</button></div>${models.length > 0
2496
3110
  ? html `<datalist id="assistant-models">${models.map((id) => html `<option value="${id}"></option>`)}</datalist>`
2497
- : html ``}
2498
- <label>API key<input name="api_key" type="password" autocomplete="off" maxlength="4096" placeholder="${settings?.keyHint ? 'Leave empty to keep it' : 'Optional for a local model'}"></label>
2499
- </div>
2500
- <p class="fine">Key: ${settings?.keyHint ? html `set, ending in <code>${settings.keyHint}</code>` : 'none'}. OpenAI-compatible covers OpenAI, OpenRouter, vLLM, Ollama and LM Studio; give the URL that ends in <code>/v1</code>.</p>
2501
- ${settings?.keyHint ? html `<label class="choice"><input type="checkbox" name="clear_key" value="1"> Remove the key</label>` : html ``}
2502
- <div class="row-actions"><button class="primary" type="submit">Save</button><button type="submit" formaction="${root}/settings/assistant/models">Load models</button></div>
2503
- </form>
2504
- ${sentTo}
2505
- ${settings
2506
- ? html `<div class="row-actions"><form class="inline" method="post" action="${root}/settings/assistant/test"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Test connection</button></form><details class="act danger"><summary>Disconnect</summary><div class="pop"><p>Members can no longer chat. The key is deleted.</p><form method="post" action="${root}/settings/assistant/remove"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button class="danger" type="submit">Disconnect</button></form></div></details></div>`
2507
- : html ``}
2508
- </section>`;
3111
+ : html ``}</div><div class="row"><label class="row-label" for="api-key">API key</label>${keyField}</div><div class="card-foot">${sentTo(chosenName)}<button class="primary" type="submit">Save</button></div></form>`;
3112
+ const disconnect = settings
3113
+ ? html `<div class="set-card"><div class="danger-row"><strong>Disconnect ${savedName}</strong><details class="act danger"><summary>Disconnect…</summary><div class="pop"><p>The key is deleted. Members can't use the assistant until a model is connected again.</p><form method="post" action="${root}/settings/assistant/remove">${hidden()}<button class="danger" type="submit">Disconnect</button></form></div></details></div></div>`
3114
+ : html ``;
3115
+ return html `${statusCard}${form}${disconnect}`;
3116
+ }
3117
+ /** The provider and base URL a form names: a named provider's own, or Custom's two fields. */
3118
+ function formEndpoint(form) {
3119
+ const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === form.get('preset'));
3120
+ if (preset && preset.id !== 'custom')
3121
+ return { provider: preset.provider, baseUrl: preset.baseUrl };
3122
+ return { provider: form.get('provider'), baseUrl: (form.get('base_url') ?? '').trim() };
2509
3123
  }
2510
3124
  const adminOnly = (session) => settingsPage(session, 403, 'Only an owner of this organization can change the assistant.');
2511
3125
  async function saveAssistantSettings(session, form) {
@@ -2514,8 +3128,8 @@ ${settings
2514
3128
  return notFound(session);
2515
3129
  if (!isAdmin(session))
2516
3130
  return adminOnly(session);
2517
- const provider = form.get('provider');
2518
- const checkedUrl = readSlotlockAssistantBaseUrl(form.get('base_url') ?? '', { privateUrls });
3131
+ const { provider, baseUrl: givenUrl } = formEndpoint(form);
3132
+ const checkedUrl = readSlotlockAssistantBaseUrl(givenUrl, { privateUrls });
2519
3133
  const model = (form.get('model') ?? '').trim();
2520
3134
  const apiKey = (form.get('api_key') ?? '').trim();
2521
3135
  if (provider !== 'anthropic' && provider !== 'openai') {
@@ -2566,14 +3180,14 @@ ${settings
2566
3180
  if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
2567
3181
  return tooManyMessages(session, settingsPage);
2568
3182
  }
2569
- const provider = form.get('provider');
3183
+ const { provider, baseUrl: givenUrl } = formEndpoint(form);
2570
3184
  if (provider !== 'anthropic' && provider !== 'openai') {
2571
3185
  return settingsPage(session, 400, 'Choose a provider from the list.');
2572
3186
  }
2573
3187
  const typedModel = (form.get('model') ?? '').trim();
2574
3188
  const draft = {
2575
3189
  provider,
2576
- baseUrl: (form.get('base_url') ?? '').trim(),
3190
+ baseUrl: givenUrl,
2577
3191
  ...(typedModel !== '' ? { model: typedModel } : {}),
2578
3192
  };
2579
3193
  const checkedUrl = readSlotlockAssistantBaseUrl(draft.baseUrl, { privateUrls });
@@ -2655,22 +3269,34 @@ ${settings
2655
3269
  return settingsPage(session, 502, slotlockAssistantProviderMessage(error));
2656
3270
  }
2657
3271
  }
2658
- async function sendToAssistant(session, form) {
3272
+ /**
3273
+ * One member message: check it, run the turn (streaming its text and steps to `onEvent` when
3274
+ * given, stopping when `signal` aborts), and keep the conversation. Both ways to send share it.
3275
+ */
3276
+ async function runAssistantMessage(session, form, hooks = {}) {
2659
3277
  const store = assistantStore();
2660
3278
  if (!store || !assistantOptions)
2661
- return notFound(session);
3279
+ return { ok: false, status: 404, message: 'Nothing is here.' };
2662
3280
  const text = (form.get('message') ?? '').trim();
2663
3281
  if (text === '' || text.length > SLOTLOCK_ASSISTANT_MAX_MESSAGE) {
2664
- return assistantPage(session, 400, `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`);
3282
+ return {
3283
+ ok: false,
3284
+ status: 400,
3285
+ message: `Write a message of 1 to ${SLOTLOCK_ASSISTANT_MAX_MESSAGE.toLocaleString('en')} characters.`,
3286
+ };
2665
3287
  }
2666
3288
  if (!memberMessages.take(`${tenantOf(session)}\0${session.uid}`)) {
2667
- return tooManyMessages(session, assistantPage);
3289
+ return {
3290
+ ok: false,
3291
+ status: 429,
3292
+ message: 'You have sent the assistant a lot in the last minute. Wait a moment and try again.',
3293
+ };
2668
3294
  }
2669
3295
  const found = await assistantModel(session);
2670
3296
  if (found === null)
2671
- return assistantPage(session, 409, 'No model is connected.');
3297
+ return { ok: false, status: 409, message: 'No model is connected.' };
2672
3298
  if ('problem' in found)
2673
- return assistantPage(session, 409, found.problem);
3299
+ return { ok: false, status: 409, message: found.problem };
2674
3300
  const tenantRef = tenantOf(session);
2675
3301
  const member = personOf(session);
2676
3302
  try {
@@ -2679,7 +3305,16 @@ ${settings
2679
3305
  catch {
2680
3306
  // Metering never changes an answer.
2681
3307
  }
2682
- const stored = await store.getConversation(tenantRef, member);
3308
+ // The conversation the form continues (a new one when it names none, or one deleted meanwhile),
3309
+ // and the resources agents may see, so a name is matched to its id without a lookup.
3310
+ const asked = await chatOfForm(store, session, form);
3311
+ const [stored, listed] = await Promise.all([
3312
+ asked ? store.getChat(tenantRef, member, asked) : Promise.resolve(null),
3313
+ options.resources.list(tenantRef).catch(() => []),
3314
+ ]);
3315
+ const chatId = stored?.id ?? newChatId();
3316
+ const title = stored?.title ?? chatTitle(text);
3317
+ const bookable = listed.filter((resource) => resource.visibility !== 'private' && resource.archivedAt === undefined);
2683
3318
  const entries = boundSlotlockAssistantEntries([
2684
3319
  ...readSlotlockAssistantEntries(stored?.messages),
2685
3320
  { role: 'user', text },
@@ -2696,12 +3331,25 @@ ${settings
2696
3331
  workspace: current?.login ?? session.login,
2697
3332
  login: session.login,
2698
3333
  now: new Date(now()),
3334
+ resources: bookable.slice(0, SLOTLOCK_ASSISTANT_DIRECTORY_SIZE).map((resource) => ({
3335
+ id: resource.id,
3336
+ ...(resource.name !== undefined ? { name: resource.name } : {}),
3337
+ ...(resource.externalRef ? { ref: resource.externalRef } : {}),
3338
+ ...(resource.kind !== undefined ? { kind: resource.kind } : {}),
3339
+ ...(resource.locationName !== undefined || resource.location !== undefined
3340
+ ? { location: (resource.locationName ?? resource.location) }
3341
+ : {}),
3342
+ timezone: resource.timezone,
3343
+ })),
3344
+ resourceCount: bookable.length,
2699
3345
  }),
2700
3346
  entries,
2701
3347
  fetch: assistantFetch,
2702
3348
  timeoutMs: assistantTimeout,
2703
3349
  turnTimeoutMs: assistantTurnTimeout,
2704
3350
  ...(assistantOptions.maxSteps !== undefined ? { maxSteps: assistantOptions.maxSteps } : {}),
3351
+ ...(hooks.onEvent ? { onEvent: hooks.onEvent } : {}),
3352
+ ...(hooks.signal ? { signal: hooks.signal } : {}),
2705
3353
  read: async (operation, input) => {
2706
3354
  // A turn's reads share one budget of resource-days, so 8 steps of wide reads cannot each
2707
3355
  // scan a hundred resources over years.
@@ -2753,134 +3401,374 @@ ${settings
2753
3401
  // Only this turn's proposals are added; the rest are kept as the row holds them now, so one
2754
3402
  // approved (or a conversation reset) while the turn ran is never put back as it stood.
2755
3403
  const kept = pruneSlotlockAssistantActions(messages, { ...(stored?.actions ?? {}), ...proposed });
2756
- await store.putConversation(tenantRef, member, {
3404
+ await store.putChat(tenantRef, member, chatId, {
3405
+ title,
2757
3406
  messages,
2758
3407
  added: Object.fromEntries(Object.entries(proposed).filter(([id]) => id in kept)),
2759
3408
  keep: Object.keys(kept),
3409
+ // A chat it continued, only while it is still there: deleted during the turn, it stays so.
3410
+ create: stored === null,
2760
3411
  }, conversationTtl);
2761
- return redirect(303, `${root}/assistant#latest`);
3412
+ return { ok: true, messages, actions: kept, chatId, title };
3413
+ }
3414
+ /** The form's way (no script): run the turn, then show the conversation at its latest message. */
3415
+ async function sendToAssistant(session, form) {
3416
+ const run = await runAssistantMessage(session, form);
3417
+ if (!run.ok) {
3418
+ return run.status === 404
3419
+ ? notFound(session)
3420
+ : assistantPage(session, run.status, run.message, chatOf(form.get('chat')) ?? 'new');
3421
+ }
3422
+ return redirect(303, `${chatUrl(run.chatId)}#latest`);
3423
+ }
3424
+ /**
3425
+ * app.js's way: the same turn, answered as it happens, one JSON object a line. `text` carries a
3426
+ * piece of the reply, `step` a tool step as it starts and ends, and the last line is `done` (the
3427
+ * turn as the page renders it, which replaces what was streamed) or `error`, each with a fresh
3428
+ * single-use value for the next send. The member's Stop aborts the request, and the turn with it.
3429
+ */
3430
+ async function streamToAssistant(session, form, request) {
3431
+ const encoder = new TextEncoder();
3432
+ // Stop, or a client gone, cancels the body. That stops the turn, which keeps what it had, and
3433
+ // the cancel settles only once it has: a Worker closes the request's connection then, and a
3434
+ // runtime that never aborts request.signal on a disconnect stops the turn all the same.
3435
+ const stop = new AbortController();
3436
+ let open = true;
3437
+ let sink;
3438
+ let work = Promise.resolve();
3439
+ const body = new ReadableStream({
3440
+ start(controller) {
3441
+ sink = controller;
3442
+ },
3443
+ cancel() {
3444
+ open = false;
3445
+ stop.abort();
3446
+ return work;
3447
+ },
3448
+ });
3449
+ const write = (event) => {
3450
+ if (!open)
3451
+ return;
3452
+ try {
3453
+ sink?.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
3454
+ }
3455
+ catch {
3456
+ open = false;
3457
+ }
3458
+ };
3459
+ const label = (name, state) => slotlockAssistantStepLabel(name, toolTitles.get(name) ?? name, state, SLOTLOCK_ASSISTANT_TOOL_USE[name] === 'propose');
3460
+ const failed = (message) => write({
3461
+ t: 'error',
3462
+ html: html `<li class="turn error" role="alert"><span>${message}</span></li>`.value,
3463
+ once: formNonce(session),
3464
+ });
3465
+ work = (async () => {
3466
+ try {
3467
+ const run = await runAssistantMessage(session, form, {
3468
+ signal: AbortSignal.any([request.signal, stop.signal]),
3469
+ onEvent: (event) => {
3470
+ if (event.type === 'text')
3471
+ write({ t: 'text', d: event.delta });
3472
+ else if (event.type === 'call')
3473
+ write({ t: 'step', id: event.id, s: 'run', label: label(event.name, 'run') });
3474
+ else
3475
+ write({ t: 'step', id: event.id, s: event.ok ? 'ok' : 'bad', label: label(event.name, event.ok ? 'ok' : 'bad') });
3476
+ },
3477
+ });
3478
+ if (!run.ok) {
3479
+ failed(run.message);
3480
+ return;
3481
+ }
3482
+ // The turn from the member's message on, as a reload would show it.
3483
+ const start = run.messages.map((entry) => entry.role).lastIndexOf('user');
3484
+ const turn = run.messages.slice(Math.max(0, start));
3485
+ const lookups = Object.keys(run.actions).length > 0
3486
+ ? await resourceLookups(tenantOf(session))
3487
+ : { names: new Map(), zones: new Map() };
3488
+ const chats = await assistantStore()?.listChats(tenantOf(session), personOf(session));
3489
+ write({
3490
+ t: 'done',
3491
+ html: assistantItems(session, turn, run.actions, lookups.names, lookups.zones, run.chatId).join(''),
3492
+ once: formNonce(session),
3493
+ // The conversation it went to (a new one has its id now), its address, and the list again.
3494
+ chat: run.chatId,
3495
+ title: run.title,
3496
+ url: chatUrl(run.chatId),
3497
+ chats: chats ? chatsPanel(session, chats, run.chatId).value : undefined,
3498
+ });
3499
+ }
3500
+ catch (error) {
3501
+ report(error instanceof Error ? error.message : 'assistant stream failed');
3502
+ failed('Something went wrong. Reload the page and try again.');
3503
+ }
3504
+ finally {
3505
+ if (open) {
3506
+ open = false;
3507
+ try {
3508
+ sink?.close();
3509
+ }
3510
+ catch {
3511
+ // Cancelled meanwhile.
3512
+ }
3513
+ }
3514
+ }
3515
+ })();
3516
+ ignoreRejection(work);
3517
+ const page = respond(200, '');
3518
+ const headers = new Headers(page.headers);
3519
+ headers.set('Content-Type', 'application/x-ndjson; charset=utf-8');
3520
+ // Proxies that buffer (nginx) send each line as it is written.
3521
+ headers.set('X-Accel-Buffering', 'no');
3522
+ return new Response(body, { status: 200, headers });
2762
3523
  }
2763
3524
  async function settleAction(session, form, decision) {
2764
3525
  const store = assistantStore();
2765
3526
  if (!store || !assistantOptions)
2766
3527
  return notFound(session);
2767
3528
  const id = form.get('action') ?? '';
3529
+ const chatId = await chatOfForm(store, session, form, true);
2768
3530
  const tenantRef = tenantOf(session);
2769
3531
  const member = personOf(session);
2770
- const claimed = ACTION_ID.test(id)
2771
- ? await store.settleAction(tenantRef, member, id, 'pending', decision === 'approve' ? { status: 'approved' } : { status: 'dismissed', input: {} })
3532
+ const claimed = ACTION_ID.test(id) && chatId
3533
+ ? await store.settleAction(tenantRef, member, chatId, id, 'pending', decision === 'approve' ? { status: 'approved' } : { status: 'dismissed', input: {} })
2772
3534
  : null;
2773
3535
  const action = claimed ? readAction(id, claimed) : null;
2774
- if (!action)
2775
- return assistantPage(session, 409, 'That change was already approved or dismissed.');
3536
+ if (!action || !chatId) {
3537
+ return assistantPage(session, 409, 'That change was already approved or dismissed.', chatId ?? undefined);
3538
+ }
2776
3539
  const title = toolTitles.get(action.operation) ?? action.operation;
2777
3540
  let event;
2778
3541
  if (decision === 'dismiss') {
2779
3542
  event = `The member dismissed the proposed change ${id} (${title}); it did not run.`;
2780
3543
  }
2781
3544
  else if (SLOTLOCK_ASSISTANT_TOOL_USE[action.operation] !== 'propose') {
2782
- await store.settleAction(tenantRef, member, id, 'approved', { status: 'failed', outcome: 'tool_not_available', input: {} });
3545
+ await store.settleAction(tenantRef, member, chatId, id, 'approved', { status: 'failed', outcome: 'tool_not_available', input: {} });
2783
3546
  event = `The proposed change ${id} (${title}) could not run: tool_not_available.`;
2784
3547
  }
2785
3548
  else {
2786
3549
  const outcome = await dispatch(session, action.operation, action.input, 'run');
2787
3550
  // Settled, the card keeps what it said, not the arguments: the actions stay small.
2788
- await store.settleAction(tenantRef, member, id, 'approved', outcome.ok ? { status: 'done', input: {} } : { status: 'failed', outcome: outcome.code, input: {} });
3551
+ await store.settleAction(tenantRef, member, chatId, id, 'approved', outcome.ok ? { status: 'done', input: {} } : { status: 'failed', outcome: outcome.code, input: {} });
2789
3552
  const result = JSON.stringify(outcome.ok ? outcome.data : { error: { code: outcome.code } });
2790
3553
  event = `The member approved the proposed change ${id} (${title}). ${outcome.ok ? 'It ran' : 'It was refused'}: ${result.length > 4_000 ? `${result.slice(0, 4_000)} …[truncated]` : result}`;
2791
3554
  }
2792
- const stored = await store.getConversation(tenantRef, member);
3555
+ const stored = await store.getChat(tenantRef, member, chatId);
2793
3556
  if (stored) {
2794
3557
  const entries = [
2795
3558
  ...readSlotlockAssistantEntries(stored.messages),
2796
3559
  { role: 'event', text: event },
2797
3560
  ];
2798
3561
  const messages = boundSlotlockAssistantEntries(entries);
2799
- await store.putConversation(tenantRef, member, { messages, added: {}, keep: Object.keys(pruneSlotlockAssistantActions(messages, stored.actions)) }, conversationTtl);
2800
- }
2801
- return redirect(303, `${root}/assistant#action-${id}`);
3562
+ await store.putChat(tenantRef, member, chatId, {
3563
+ title: stored.title,
3564
+ messages,
3565
+ added: {},
3566
+ keep: Object.keys(pruneSlotlockAssistantActions(messages, stored.actions)),
3567
+ create: false,
3568
+ }, conversationTtl);
3569
+ }
3570
+ return redirect(303, `${chatUrl(chatId)}#action-${id}`);
2802
3571
  }
3572
+ /** A new conversation: the others are kept, in Chats. (A form of an earlier release posts here.) */
2803
3573
  async function resetAssistant(session) {
3574
+ if (!assistantStore())
3575
+ return notFound(session);
3576
+ return redirect(303, chatUrl(null));
3577
+ }
3578
+ /** Give one of the member's conversations a title of their own. */
3579
+ async function renameChat(session, form) {
2804
3580
  const store = assistantStore();
2805
3581
  if (!store)
2806
3582
  return notFound(session);
2807
- await store.deleteConversation(tenantOf(session), personOf(session));
2808
- return redirect(303, `${root}/assistant`);
3583
+ const chatId = chatOf(form.get('chat'));
3584
+ const title = (form.get('title') ?? '').replace(/\s+/gu, ' ').trim();
3585
+ if (!chatId)
3586
+ return assistantPage(session, 404, 'That chat is not here: it was deleted.', 'new');
3587
+ if ([...title].length < 1 || [...title].length > 120) {
3588
+ return assistantPage(session, 400, 'Give the chat a title of 1 to 120 characters.', chatId);
3589
+ }
3590
+ const renamed = await store.renameChat(tenantOf(session), personOf(session), chatId, title);
3591
+ if (!renamed)
3592
+ return assistantPage(session, 404, 'That chat is not here: it was deleted.', 'new');
3593
+ return redirect(303, chatUrl(chatId));
2809
3594
  }
2810
- async function planPage(session, status = 200, notice, thanks = false) {
2811
- const billing = options.billing;
2812
- if (!billing)
3595
+ /** Delete one of the member's conversations, at once and for good. */
3596
+ async function deleteChat(session, form) {
3597
+ const store = assistantStore();
3598
+ if (!store)
2813
3599
  return notFound(session);
2814
- if (session.env === 'test') {
2815
- // Its own use, counted apart from Live's and never billed.
2816
- const range = slotlockUsageMonthToDate(now());
2817
- const tested = options.usage && options.resources.usage
2818
- ? summarizeSlotlockUsage(await options.resources.usage(tenantOf(session), range.from, range.to))
2819
- : null;
2820
- const stat = (label, value) => html `<div class="stat"><span class="stat-label">${label}</span><span class="stat-value">${value.toLocaleString('en-GB')}</span></div>`;
2821
- return respond(status, layout('Plan', html `${pageHead('Plan')}${noticeOf(notice)}<p>The test environment is on your live plan's limits and is never billed. Switch to Live to see your plan, or to change it.</p>${tested
2822
- ? html `<section class="panel" id="usage"><h2>Test usage this month</h2><div class="stats">${stat('Operations', tested.totals.operations)}${stat('Bookings', tested.totals.bookings)}${stat('Holds', tested.totals.holds)}</div><p class="fine">Since ${range.from}, in UTC, counted apart from Live, against the same monthly quotas.</p></section>`
2823
- : html ``}`, session, 'plan'));
2824
- }
3600
+ const chatId = chatOf(form.get('chat'));
3601
+ if (chatId)
3602
+ await store.deleteChat(tenantOf(session), personOf(session), chatId);
3603
+ return redirect(303, chatUrl(null));
3604
+ }
3605
+ /**
3606
+ * A workspace's plan as both the Usage page and the overview show it: the plan (or the default it
3607
+ * moves to), what it has against the limits, this month's use against the quotas, and what needs a
3608
+ * person: a failed payment, a quota used up or nearly, more than the coming plan allows.
3609
+ * `here` is the page asking, so an item's action points to the page that resolves it.
3610
+ */
3611
+ async function planStanding(session, here, given) {
3612
+ const active = plans;
3613
+ const billing = options.billing;
2825
3614
  const tenantRef = tenantOf(session);
2826
- const [plan, resourceCount, keys, webhooks] = await Promise.all([
2827
- billing.plan(tenantRef),
3615
+ const [plan, planDefault, resourceCount, keys, webhooks] = await Promise.all([
3616
+ active.plan(tenantRef),
3617
+ active.planDefault ? active.planDefault() : Promise.resolve(null),
2828
3618
  // As the limit counts them: archived resources included.
2829
3619
  options.resources.count
2830
3620
  ? options.resources.count(tenantRef)
2831
3621
  : options.resources.list(tenantRef).then((listed) => listed.length),
2832
- options.keys.list({ tenantRef }),
3622
+ given ?? options.keys.list({ tenantRef }),
2833
3623
  options.resources.webhooks?.list(tenantRef),
2834
3624
  ]);
2835
3625
  const month = slotlockUsageMonthToDate(now());
2836
3626
  const used = options.usage && options.resources.usage
2837
3627
  ? summarizeSlotlockUsage(await options.resources.usage(tenantRef, month.from, month.to))
2838
3628
  : null;
2839
- const keyName = new Map(keys.map((key) => [key.id, key.name]));
2840
- const active = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now()));
2841
- const token = csrfToken(session);
3629
+ const keyCount = keys.filter((key) => !key.revokedAt && (key.expiresAt === null || key.expiresAt.getTime() > now())).length;
3630
+ const admin = isAdmin(session);
2842
3631
  const paying = plan !== null && plan.plan !== 'free';
2843
- const name = plan ? PLAN_NAMES[plan.plan] : 'No plan';
2844
- // An organization is offered Team where the deployment sells it; a person, Developer.
2845
- const offered = session.org !== undefined && billing.teamPrices ? 'team' : 'developer';
2846
- const offeredPrices = offered === 'team' ? billing.teamPrices : billing.prices;
2847
- const counted = (value) => value.toLocaleString('en-GB');
2848
- const use = (label, used, limit) => html `<div class="stat"><span class="stat-label">${label}</span><span class="stat-value">${counted(used)}${limit === undefined ? html `` : html `<span class="meta"> of ${counted(limit)}</span>`}</span></div>`;
2849
- // This month's quotas, when the deployment holds tenants to them.
3632
+ const upcoming = !plan && planDefault && planDefault.appliesFrom.getTime() > now() ? planDefault : null;
3633
+ const limits = plan?.limits ?? upcoming?.limits;
2850
3634
  const quotas = options.quotas && plan ? SLOTLOCK_PLAN_QUOTAS[plan.plan] : null;
2851
3635
  const resets = formatDate(slotlockQuotaPeriod(now()).resetsAt);
2852
- const quotaWarnings = quotas && used
2853
- ? ['operations', 'bookings'].flatMap((metric) => {
3636
+ const counted = (value) => value.toLocaleString('en-GB');
3637
+ const usagePage = `${root}/plan`;
3638
+ const upgradeAt = here === 'usage' ? '#upgrade' : `${usagePage}#upgrade`;
3639
+ const canUpgrade = !paying && admin && Boolean(billing || active.contact);
3640
+ const upgradeHint = paying ? '' : admin ? ' Upgrade for more.' : ' An owner of this organization can upgrade.';
3641
+ const attention = [];
3642
+ // Whether anything is near or past a limit: the overview then offers the upgrade.
3643
+ let pressing = false;
3644
+ if (plan?.status === 'past_due') {
3645
+ attention.push(attentionItem('bad', `Your last payment failed. ${admin && billing && plan.stripeCustomer ? 'Update your payment method in Manage billing' : 'An owner updates the payment method'} to keep the ${PLAN_NAMES[plan.plan]} plan.`, here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined));
3646
+ }
3647
+ if (quotas && used) {
3648
+ for (const metric of ['operations', 'bookings']) {
2854
3649
  const quota = quotas[metric];
2855
3650
  const stop = Math.floor((quota * quotas.stopPercent) / 100);
2856
3651
  const spent = used.totals[metric];
2857
- const what = metric === 'operations' ? 'operations' : 'bookings';
2858
3652
  const refused = metric === 'operations' ? 'every call but reading usage' : 'new bookings and holds';
3653
+ const upgrade = canUpgrade ? { href: upgradeAt, label: 'Upgrade' } : here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined;
3654
+ if (spent * 100 >= quota * 80)
3655
+ pressing = true;
2859
3656
  if (spent >= stop) {
2860
- return [html `<p class="notice warn" role="alert">This month's ${what} are used up: Slotlock refuses ${refused} with quota_exceeded until ${resets}.${paying ? html `` : html ` Upgrade for more.`}</p>`];
3657
+ attention.push(attentionItem('bad', `This month's ${metric} are used up: Slotlock refuses ${refused} with quota_exceeded until ${resets}.${upgradeHint}`, upgrade));
2861
3658
  }
2862
- if (spent >= quota) {
2863
- const billed = metric === 'operations' && billing.overage && plan?.plan === 'developer' && paying;
2864
- return [html `<p class="notice warn" role="alert">You have used this month's ${counted(quota)} ${what}.${billed ? ` Those past it are billed at ${billing.overage?.price ?? ''}.` : ''} Slotlock refuses ${refused} past ${counted(stop)}, until ${resets}.</p>`];
3659
+ else if (spent >= quota) {
3660
+ const billed = metric === 'operations' && billing?.overage && plan?.plan === 'developer' && paying;
3661
+ attention.push(attentionItem('warn', `You have used this month's ${counted(quota)} ${metric}.${billed ? ` Those past it are billed at ${billing?.overage?.price ?? ''}.` : ''} Slotlock refuses ${refused} past ${counted(stop)}, until ${resets}.${billed ? '' : upgradeHint}`, billed ? undefined : upgrade));
2865
3662
  }
2866
- if (spent * 100 >= quota * 80) {
2867
- return [html `<p class="notice warn" role="status">You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${what}. They start again on ${resets}.</p>`];
3663
+ else if (spent * 100 >= quota * 80) {
3664
+ attention.push(attentionItem('warn', `You have used ${String(Math.floor((spent * 100) / quota))}% of this month's ${metric}. They start again on ${resets}.`, here === 'overview' ? { href: usagePage, label: 'Usage' } : undefined));
2868
3665
  }
2869
- return [];
2870
- })
2871
- : [];
2872
- const warning = html `${plan?.status === 'past_due'
2873
- ? html `<p class="notice warn" role="alert">Your last payment failed. Update your payment method to keep the ${name} plan.</p>`
2874
- : html ``}${quotaWarnings}`;
2875
- const free = SLOTLOCK_PLAN_LIMITS.free;
2876
- const developer = SLOTLOCK_PLAN_LIMITS[offered];
2877
- const upgrade = paying || !isAdmin(session)
2878
- ? html ``
2879
- : html `<section class="panel" id="upgrade">
3666
+ }
3667
+ }
3668
+ if (upcoming) {
3669
+ // What the workspace has that its coming plan will not allow: nothing is removed; adding stops.
3670
+ const over = [
3671
+ ['resources', resourceCount, upcoming.limits.resources],
3672
+ ['active API keys', keyCount, upcoming.limits.apiKeys],
3673
+ ['webhooks', webhooks?.length ?? 0, upcoming.limits.webhookEndpoints],
3674
+ ].filter(([, have, allowed]) => have > allowed);
3675
+ if (over.length > 0)
3676
+ pressing = true;
3677
+ attention.push(attentionItem(over.length > 0 ? 'warn' : 'info', `From ${formatDate(upcoming.appliesFrom)}, this workspace is on ${PLAN_NAMES[upcoming.plan]}: ${String(upcoming.limits.resources)} resources, ${String(upcoming.limits.apiKeys)} API keys and ${String(upcoming.limits.webhookEndpoints)} webhooks.${over.length > 0
3678
+ ? ` You have ${over.map(([what, have]) => `${String(have)} ${what}`).join(', ')}: nothing is removed, but you cannot add more until you have fewer${paying || !admin ? '' : ', or upgrade'}.`
3679
+ : ''}`, canUpgrade && over.length > 0 ? { href: upgradeAt, label: 'See plans' } : undefined));
3680
+ }
3681
+ /** One use against its allowance: a bar, amber from 80%, red at the limit. */
3682
+ const meter = (label, value, limit) => {
3683
+ if (limit === undefined) {
3684
+ return html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
3685
+ }
3686
+ const tone = value >= limit ? ' bad' : value * 100 >= limit * 80 ? ' warn' : '';
3687
+ return html `<div class="meter${tone}"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}<span class="meta"> of ${counted(limit)}</span></span></div><progress value="${String(Math.min(value, limit))}" max="${String(limit)}" aria-label="${label}: ${counted(value)} of ${counted(limit)}"></progress></div>`;
3688
+ };
3689
+ const limitMeters = html `${meter('Resources', resourceCount, limits?.resources)}${meter('Active API keys', keyCount, limits?.apiKeys)}${webhooks ? meter('Webhooks', webhooks.length, limits?.webhookEndpoints) : html ``}`;
3690
+ // Under a "This month" heading, or beside the limits with the month in each label.
3691
+ const monthMeters = (suffix) => used
3692
+ ? html `${meter(`Operations${suffix}`, used.totals.operations, quotas?.operations)}${meter(`Bookings${suffix}`, used.totals.bookings, quotas?.bookings)}${meter(`Holds${suffix}`, used.totals.holds, undefined)}`
3693
+ : html ``;
3694
+ const name = plan
3695
+ ? PLAN_NAMES[plan.plan]
3696
+ : upcoming
3697
+ ? `${PLAN_NAMES[upcoming.plan]} from ${dayOf(upcoming.appliesFrom)}`
3698
+ : 'No plan';
3699
+ return {
3700
+ plan,
3701
+ upcoming,
3702
+ paying,
3703
+ admin,
3704
+ used,
3705
+ month,
3706
+ resets,
3707
+ keys,
3708
+ name,
3709
+ limitMeters,
3710
+ monthMeters,
3711
+ meters: html `${limitMeters}${monthMeters(' this month')}`,
3712
+ attention,
3713
+ canUpgrade,
3714
+ pressing,
3715
+ };
3716
+ }
3717
+ /** One thing that needs a person: a sentence, its tone, and what resolves it. */
3718
+ const attentionItem = (tone, text, action) => html `<li class="attention ${tone}"><span class="attention-text">${text}</span>${action ? html `<a class="attention-action" href="${action.href}">${action.label}</a>` : html ``}</li>`;
3719
+ /** The list of things that need a person, or nothing. */
3720
+ const attentionList = (items) => items.length > 0 ? html `<ul class="attention-list" aria-label="Needs attention">${items}</ul>` : html ``;
3721
+ async function planPage(session, status = 200, notice, thanks = false) {
3722
+ if (!plans)
3723
+ return notFound(session);
3724
+ const billing = options.billing;
3725
+ const counted = (value) => value.toLocaleString('en-GB');
3726
+ if (session.env === 'test') {
3727
+ // Its own use, counted apart from Live's and never billed.
3728
+ const range = slotlockUsageMonthToDate(now());
3729
+ const tested = options.usage && options.resources.usage
3730
+ ? summarizeSlotlockUsage(await options.resources.usage(tenantOf(session), range.from, range.to))
3731
+ : null;
3732
+ const meter = (label, value) => html `<div class="meter"><div class="meter-head"><span>${label}</span><span class="meter-value">${counted(value)}</span></div></div>`;
3733
+ return respond(status, layout('Usage', html `${pageHead('Usage')}${noticeOf(notice, status)}<p>The test environment is on your live plan's limits and is never billed. Switch to Live to see your plan, or to change it.</p>${tested
3734
+ ? html `<section class="panel" id="usage"><h2>This month, in Test</h2><div class="meters">${meter('Operations', tested.totals.operations)}${meter('Bookings', tested.totals.bookings)}${meter('Holds', tested.totals.holds)}</div><p class="fine">Since ${range.from}, in UTC, counted apart from Live, against the same monthly quotas.</p></section>`
3735
+ : html ``}`, session, 'plan'));
3736
+ }
3737
+ const standing = await planStanding(session, 'usage');
3738
+ const { plan, paying, admin, used, month, resets, keys } = standing;
3739
+ const token = csrfToken(session);
3740
+ const keyName = new Map(keys.map((key) => [key.id, key.name]));
3741
+ // An organization is offered Team (where billing sells it, or by asking); a person, Developer.
3742
+ const offered = session.org !== undefined && (billing ? Boolean(billing.teamPrices) : true) ? 'team' : 'developer';
3743
+ const contact = plans.contact ?? billing?.contact;
3744
+ const manage = billing && plan?.stripeCustomer && admin
3745
+ ? html `<form class="inline" method="post" action="${root}/plan/manage"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Manage billing</button></form>`
3746
+ : html ``;
3747
+ const renews = paying && plan?.currentPeriodEnd
3748
+ ? html `<span class="meta">${plan.status === 'past_due' ? 'Payment due since' : 'Renews'} ${formatDate(plan.currentPeriodEnd)}</span>`
3749
+ : html ``;
3750
+ const planCard = html `<section class="panel plan-card" id="plan"><div class="plan-head"><h2>${standing.name}</h2>${renews}</div><div class="meters">${standing.limitMeters}</div>${used ? html `` : html `<p class="fine">This server does not count use yet.</p>`}</section>${used
3751
+ ? html `<section class="panel" id="month"><h2>This month</h2><div class="meters">${standing.monthMeters('')}</div><p class="fine">Since ${month.from} (UTC); the month starts again on ${resets}. An operation is a call that reached Slotlock, whatever it answered.</p></section>`
3752
+ : html ``}`;
3753
+ const byKey = used && used.keys.length > 0
3754
+ ? html `<section class="panel" id="usage"><h2>Used by</h2><div class="table"><table><thead><tr><th>Key</th><th>Operations</th><th>Bookings</th><th>Holds</th></tr></thead><tbody>${used.keys.map((row) => html `<tr><td data-label="Key">${row.keyId === null ? 'The dashboard and its assistant' : (keyName.get(row.keyId) ?? 'A deleted key')}</td><td data-label="Operations">${counted(row.operations)}</td><td data-label="Bookings">${counted(row.bookings)}</td><td data-label="Holds">${counted(row.holds)}</td></tr>`)}</tbody></table></div><p class="fine">Webhook deliveries ${counted(used.totals.webhook_deliveries)} · assistant messages ${counted(used.totals.assistant_messages)}.</p></section>`
3755
+ : html ``;
3756
+ const offeredLimits = SLOTLOCK_PLAN_LIMITS[offered];
3757
+ const offeredText = html `${String(offeredLimits.resources)} resources, ${String(offeredLimits.apiKeys)} API keys and ${String(offeredLimits.webhookEndpoints)} webhook endpoints${options.quotas
3758
+ ? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS[offered].operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS[offered].bookings)} bookings a month${offered === 'team' ? ' shared by the organization' : ''}`
3759
+ : html ``}.`;
3760
+ let upgrade = html ``;
3761
+ if (!paying) {
3762
+ if (!admin) {
3763
+ upgrade = html `<p class="fine">An owner of this organization can change its plan.</p>`;
3764
+ }
3765
+ else if (billing) {
3766
+ const offeredPrices = offered === 'team' ? billing.teamPrices : billing.prices;
3767
+ upgrade = html `<section class="panel" id="upgrade">
2880
3768
  <h2>${PLAN_NAMES[offered]}</h2>
2881
- <p>${String(developer.resources)} resources, ${String(developer.apiKeys)} API keys and ${String(developer.webhookEndpoints)} webhook endpoints${options.quotas ? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS[offered].operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS[offered].bookings)} bookings a month${offered === 'team' ? ' shared by the organization' : ''}` : html ``}.${offered === 'developer' && billing.overage && options.quotas
2882
- ? ` Operations past them are billed at ${billing.overage.price}, up to ${String(billing.overage.stopPercent)}% of them.`
2883
- : ''}</p>
3769
+ <p>${offeredText}${offered === 'developer' && billing.overage && options.quotas
3770
+ ? ` Operations past them are billed at ${billing.overage.price}, up to ${String(billing.overage.stopPercent)}% of them.`
3771
+ : ''}</p>
2884
3772
  <form method="post" action="${root}/plan/upgrade">
2885
3773
  <input type="hidden" name="csrf" value="${token}">
2886
3774
  <input type="hidden" name="once" value="${formNonce(session)}">
@@ -2892,46 +3780,22 @@ ${settings
2892
3780
  </form>
2893
3781
  <p class="fine">Paid through Stripe, in your currency, with tax added where it applies. Cancel at any time.</p>
2894
3782
  </section>`;
2895
- const manage = plan?.stripeCustomer && isAdmin(session)
2896
- ? html `<form class="inline" method="post" action="${root}/plan/manage"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><button type="submit">Manage billing</button></form>`
2897
- : html ``;
2898
- const renews = paying && plan?.currentPeriodEnd
2899
- ? html `<p class="fine">${plan.status === 'past_due' ? 'Payment due since' : 'Renews'} ${formatDate(plan.currentPeriodEnd)}.</p>`
2900
- : html ``;
2901
- const usageSection = used
2902
- ? html `<section class="panel" id="usage">
2903
- <h2>Usage this month</h2>
2904
- <div class="stats">
2905
- ${use('Operations', used.totals.operations, quotas?.operations)}
2906
- ${use('Bookings', used.totals.bookings, quotas?.bookings)}
2907
- ${use('Holds', used.totals.holds, undefined)}
2908
- ${use('Webhook deliveries', used.totals.webhook_deliveries, undefined)}
2909
- ${use('Assistant messages', used.totals.assistant_messages, undefined)}
2910
- </div>
2911
- <p class="fine">Since ${month.from}, in UTC. An operation is a call that reached Slotlock, whatever it answered; it shows here within 15 seconds. A booking is a reservation, a calendar event or a confirmed hold, and with holds shows within the hour.</p>
2912
- ${used.keys.length === 0
2913
- ? html ``
2914
- : html `<div class="table"><table><thead><tr><th>Used by</th><th>Operations</th><th>Bookings</th><th>Holds</th></tr></thead><tbody>${used.keys.map((row) => html `<tr><td data-label="Used by">${row.keyId === null ? 'The dashboard and its assistant' : (keyName.get(row.keyId) ?? 'A deleted key')}</td><td data-label="Operations">${String(row.operations)}</td><td data-label="Bookings">${String(row.bookings)}</td><td data-label="Holds">${String(row.holds)}</td></tr>`)}</tbody></table></div>`}
2915
- </section>`
2916
- : html ``;
2917
- const content = html `${thanks ? html `<p class="notice">${notice ?? ''}</p>` : noticeOf(notice)}
2918
- ${warning}
2919
- ${pageHead(`Plan: ${name}`, manage)}
2920
- <div class="stats">
2921
- ${use('Resources', resourceCount, plan?.limits.resources)}
2922
- ${use('Active API keys', active.length, plan?.limits.apiKeys)}
2923
- ${webhooks ? use('Webhooks', webhooks.length, plan?.limits.webhookEndpoints) : html ``}
2924
- </div>
2925
- ${renews}
2926
- ${usageSection}
2927
- ${plan ? html `` : html `<p class="fine">Free allows ${String(free.resources)} resources, ${String(free.apiKeys)} API keys and ${String(free.webhookEndpoints)} webhook endpoints${options.quotas ? html `, with ${counted(SLOTLOCK_PLAN_QUOTAS.free.operations)} operations and ${counted(SLOTLOCK_PLAN_QUOTAS.free.bookings)} bookings a month` : html ``}.</p>`}
3783
+ }
3784
+ else if (contact) {
3785
+ // No payment here yet: the plan is arranged by asking.
3786
+ upgrade = html `<section class="panel" id="upgrade"><h2>${PLAN_NAMES[offered]}</h2><p>${offeredText}</p><a class="button primary" href="${contact}">Talk to us about ${PLAN_NAMES[offered]}</a></section>`;
3787
+ }
3788
+ }
3789
+ const content = html `${thanks ? html `<p class="notice done" role="status">${notice ?? ''}</p>` : noticeOf(notice, status)}
3790
+ ${pageHead('Usage', manage)}
3791
+ ${attentionList(standing.attention)}
3792
+ ${planCard}
3793
+ ${byKey}
2928
3794
  ${upgrade}
2929
- <section class="panel" id="custom">
2930
- <h2>Custom</h2>
2931
- <p>Slotlock on your own servers, with support from the people who build it.</p>
2932
- <a class="button" href="${billing.contact}">Talk to the founder</a>
2933
- </section>`;
2934
- return respond(status, layout('Plan', content, session, 'plan'));
3795
+ ${contact
3796
+ ? html `<section class="panel" id="custom"><h2>Custom</h2><p>Slotlock on your own servers, with support from the people who build it.</p><a class="button" href="${contact}">Talk to the founder</a></section>`
3797
+ : html ``}`;
3798
+ return respond(status, layout('Usage', content, session, 'plan'));
2935
3799
  }
2936
3800
  async function upgradePlan(session, form) {
2937
3801
  const billing = options.billing;
@@ -3001,25 +3865,54 @@ ${upgrade}
3001
3865
  if ((route.startsWith('/locations') || route.startsWith('/groups')) && options.resources.grouping) {
3002
3866
  return locationsPage;
3003
3867
  }
3868
+ if (route.startsWith('/closures') && options.resources.hours)
3869
+ return closuresPage;
3870
+ if (route.startsWith('/contacts') && options.resources.contacts)
3871
+ return contactsPage;
3004
3872
  if (route.startsWith('/resources') || route === '/hours')
3005
3873
  return resourcesPage;
3006
3874
  if (route.startsWith('/assistant') && assistantStore())
3007
3875
  return assistantPage;
3008
3876
  if (route.startsWith('/settings'))
3009
3877
  return settingsPage;
3010
- if (route.startsWith('/plan') && options.billing)
3878
+ if (route.startsWith('/plan') && plans)
3011
3879
  return planPage;
3012
3880
  if (route.startsWith('/teams') && teams)
3013
3881
  return teamsPage;
3014
3882
  return overviewPage;
3015
3883
  }
3884
+ const isLocationTarget = (target) => typeof target === 'object' && 'location' in target && typeof target.location === 'object';
3885
+ /** How many resources keep hours of their own, a few by name, and what saving does to them. */
3886
+ function ownHoursChoice(own) {
3887
+ if (own.length === 0)
3888
+ return html ``;
3889
+ const named = own.slice(0, OWN_HOURS_NAMED);
3890
+ const word = own.length === 1 ? 'resource keeps hours of its own' : 'resources keep hours of their own';
3891
+ return html `<fieldset class="modes"><legend>${String(own.length)} ${word}</legend>
3892
+ <p class="fine">${named.map((resource, index) => html `${index > 0 ? ', ' : ''}<a href="${root}/hours?resource=${resource.id}">${resourceName(resource, resource.id)}</a>`)}${own.length > named.length ? html ` and ${String(own.length - named.length)} more` : html ``}. <a href="${root}/resources?hours=own">See them all</a></p>
3893
+ <label class="choice"><input type="radio" name="override" value="none" checked> Keep them</label>
3894
+ <label class="choice"><input type="radio" name="override" value="matching"> Clear those with the same hours as yours were, or as these: copies an earlier version made when your hours were set</label>
3895
+ <label class="choice"><input type="radio" name="override" value="all"> Clear them all, so every resource uses these</label>
3896
+ </fieldset>`;
3897
+ }
3016
3898
  function hoursPage(session, state) {
3017
3899
  const { target, mode } = state;
3018
3900
  const checked = (value) => (mode === value ? html ` checked` : html ``);
3019
3901
  const server = options.availability ?? [];
3902
+ const place = isLocationTarget(target) ? target.location : null;
3903
+ const resource = target !== 'all' && !place ? target : null;
3020
3904
  const title = target === 'all'
3021
- ? 'Hours for every resource'
3022
- : `Hours for ${target.externalRef ?? target.id}`;
3905
+ ? 'Your hours'
3906
+ : place
3907
+ ? `Hours at ${place.name}`
3908
+ : `Hours for ${resource?.externalRef ?? resource?.id ?? ''}`;
3909
+ const inheritLabel = target === 'all'
3910
+ ? html `The server's hours (${server.length === 0 ? 'none' : hoursSummary(server)})`
3911
+ : place
3912
+ ? html `Inherit: a location it is in, else your hours, else the server's`
3913
+ : resource && resource.availabilityRules === undefined
3914
+ ? html `Inherit (${resourceHours(resource, server)})`
3915
+ : html `Inherit: its location's hours, else yours, else the server's`;
3023
3916
  const rows = WEEKDAYS.map((day) => {
3024
3917
  const cells = state.week[day].flatMap(({ from, to }, index) => {
3025
3918
  const which = index + 1;
@@ -3033,30 +3926,50 @@ ${upgrade}
3033
3926
  });
3034
3927
  const content = html `${state.notice ? html `<p class="notice" role="alert">${state.notice}</p>` : html ``}
3035
3928
  <section>
3036
- <p><a class="back" href="${root}/resources">Resources</a></p>
3929
+ <p><a class="back" href="${root}/${place ? 'locations' : 'resources'}">${place ? 'Locations' : 'Resources'}</a></p>
3037
3930
  <h1>${title}</h1>
3038
3931
  <p>${target === 'all'
3039
- ? 'The same hours for every resource, each in its own time zone. Saving replaces their hours.'
3040
- : `When agents may book it, in its time zone: ${target.timezone}.`}</p>
3932
+ ? 'Every resource without hours of its own uses these, each in its own time zone, unless its location has hours. Resources you add later too.'
3933
+ : place
3934
+ ? `Resources at ${place.name}, or at a location inside it, without hours of their own use these, each in its own time zone, unless a location nearer them has hours.`
3935
+ : `When agents may book it, in its time zone: ${resource?.timezone ?? ''}.`}${options.resources.hours ? html ` <a href="${root}/closures">Closures and special days</a> change single days.` : html ``}</p>
3041
3936
  ${state.unshown
3042
3937
  ? html `<div class="notice warn" role="alert"><p>These hours were set outside the dashboard, so the week below cannot show them. Saving replaces them.</p><ul class="hours">${state.unshown.map((rule) => html `<li><code>${rule.rrule}</code> from ${formatMinutes(rule.startMinutes)} for ${String(rule.durationMinutes)} min</li>`)}</ul></div>`
3043
3938
  : html ``}
3044
3939
  <form method="post" action="${root}/hours">
3045
3940
  <input type="hidden" name="csrf" value="${csrfToken(session)}">
3046
3941
  <input type="hidden" name="once" value="${formNonce(session)}">
3047
- <input type="hidden" name="resource" value="${target === 'all' ? 'all' : target.id}">
3942
+ ${place ? html `<input type="hidden" name="location" value="${place.ref}">` : html `<input type="hidden" name="resource" value="${target === 'all' ? 'all' : (resource?.id ?? '')}">`}
3048
3943
  <fieldset class="modes"><legend>Bookable hours</legend>
3049
- <label class="choice"><input type="radio" name="mode" value="server"${checked('server')}> The server's hours (${server.length === 0 ? 'none' : hoursSummary(server)})</label>
3944
+ <label class="choice"><input type="radio" name="mode" value="server"${checked('server')}> ${inheritLabel}</label>
3050
3945
  <label class="choice"><input type="radio" name="mode" value="closed"${checked('closed')}> Closed: agents find no free slot</label>
3051
3946
  <label class="choice"><input type="radio" name="mode" value="custom"${checked('custom')}> These hours</label>
3052
3947
  </fieldset>
3053
3948
  <div class="table"><table class="week"><thead><tr><th>Day</th><th>Opens</th><th>Closes</th><th>Opens</th><th>Closes</th></tr></thead><tbody>${rows}</tbody></table></div>
3054
3949
  <p class="fine">A closing time at or before the opening time closes the next day; 00:00 to 00:00 is the whole day. Leave a window empty for none.</p>
3950
+ ${target === 'all' ? ownHoursChoice(state.own ?? []) : html ``}
3055
3951
  <button class="primary" type="submit">Save hours</button>
3056
3952
  </form>
3057
3953
  </section>`;
3058
3954
  return respond(state.status ?? 200, layout('Bookable hours', content, session, 'resources'));
3059
3955
  }
3956
+ /** The editor's state for stored rules: inherit (null), closed ([]), or a week. */
3957
+ function hoursState(rules) {
3958
+ if (rules === undefined || rules === null) {
3959
+ return { mode: 'server', week: weekFormOf(rulesToWeeklyHours(options.availability ?? [])) };
3960
+ }
3961
+ if (rules.length === 0)
3962
+ return { mode: 'closed', week: emptyWeekForm() };
3963
+ const week = rulesToWeeklyHours(rules);
3964
+ return { mode: 'custom', week: weekFormOf(week), ...(week === null ? { unshown: rules } : {}) };
3965
+ }
3966
+ /** The tenant's location with that reference, or null. */
3967
+ async function ownLocation(session, ref) {
3968
+ const grouping = options.resources.grouping;
3969
+ if (!grouping || !/^[a-z0-9][a-z0-9._-]{0,99}$/.test(ref))
3970
+ return null;
3971
+ return (await grouping.locations(tenantOf(session))).find((location) => location.ref === ref) ?? null;
3972
+ }
3060
3973
  /** The tenant's resource with that id, or null for anything else. */
3061
3974
  async function ownResource(session, id) {
3062
3975
  if (!UUID_PATTERN.test(id))
@@ -3066,38 +3979,51 @@ ${state.unshown
3066
3979
  }
3067
3980
  const notFound = (session) => respond(404, layout('Not found', html `<p class="notice">That resource is not one of yours.</p>`, session));
3068
3981
  const nothingHere = (session) => respond(404, layout('Not found', html `<p class="notice">Nothing is here.</p>`, session));
3982
+ /** The tenant's resources in use that keep hours of their own, by name. */
3983
+ async function ownHoursOf(session) {
3984
+ const resources = await options.resources.list(tenantOf(session));
3985
+ return resources
3986
+ .filter((resource) => resource.archivedAt === undefined && resource.availabilityRules !== undefined)
3987
+ .sort((one, other) => resourceName(one, one.id).localeCompare(resourceName(other, other.id)));
3988
+ }
3069
3989
  async function hoursEditor(session, url) {
3990
+ const ref = url.searchParams.get('location');
3991
+ if (ref !== null) {
3992
+ const location = await ownLocation(session, ref);
3993
+ if (!location || !options.resources.hours)
3994
+ return nothingHere(session);
3995
+ const defaults = await options.resources.hours.defaults(tenantOf(session));
3996
+ const rules = defaults.locations.find((entry) => entry.location === ref)?.rules ?? null;
3997
+ return hoursPage(session, { target: { location }, ...hoursState(rules) });
3998
+ }
3070
3999
  const id = url.searchParams.get('resource');
3071
4000
  if (id === null) {
3072
- const server = rulesToWeeklyHours(options.availability ?? []);
3073
- return hoursPage(session, { target: 'all', mode: 'server', week: weekFormOf(server) });
4001
+ const [defaults, own] = await Promise.all([options.resources.hours?.defaults(tenantOf(session)), ownHoursOf(session)]);
4002
+ return hoursPage(session, { target: 'all', ...hoursState(defaults?.tenant ?? null), own });
3074
4003
  }
3075
4004
  const resource = await ownResource(session, id);
3076
4005
  if (!resource)
3077
4006
  return notFound(session);
3078
- const rules = resource.availabilityRules;
3079
- if (rules === undefined) {
3080
- const server = rulesToWeeklyHours(options.availability ?? []);
3081
- return hoursPage(session, { target: resource, mode: 'server', week: weekFormOf(server) });
3082
- }
3083
- if (rules.length === 0) {
3084
- return hoursPage(session, { target: resource, mode: 'closed', week: emptyWeekForm() });
3085
- }
3086
- const week = rulesToWeeklyHours(rules);
3087
- return hoursPage(session, {
3088
- target: resource,
3089
- mode: 'custom',
3090
- week: weekFormOf(week),
3091
- ...(week === null ? { unshown: rules } : {}),
3092
- });
4007
+ return hoursPage(session, { target: resource, ...hoursState(resource.availabilityRules) });
3093
4008
  }
4009
+ /** The form's choice for resources' own hours (`yes`: the checkbox of a page from before); anything else keeps them. */
4010
+ const overrideOf = (value) => value === 'all' || value === 'yes' ? 'all' : value === 'matching' ? 'matching' : 'none';
3094
4011
  async function saveHours(session, form) {
4012
+ const ref = form.get('location');
3095
4013
  const id = form.get('resource') ?? '';
3096
- const target = id === 'all' ? 'all' : await ownResource(session, id);
4014
+ let target;
4015
+ if (ref !== null) {
4016
+ const location = await ownLocation(session, ref);
4017
+ target = location && options.resources.hours ? { location } : null;
4018
+ }
4019
+ else {
4020
+ target = id === 'all' ? 'all' : await ownResource(session, id);
4021
+ }
3097
4022
  if (!target)
3098
4023
  return notFound(session);
3099
4024
  const mode = form.get('mode');
3100
4025
  const week = readWeekForm(form);
4026
+ const own = target === 'all' ? await ownHoursOf(session) : undefined;
3101
4027
  if (mode !== 'server' && mode !== 'closed' && mode !== 'custom') {
3102
4028
  return hoursPage(session, {
3103
4029
  target,
@@ -3105,6 +4031,7 @@ ${state.unshown
3105
4031
  week,
3106
4032
  status: 400,
3107
4033
  notice: 'Choose which hours to use.',
4034
+ own,
3108
4035
  });
3109
4036
  }
3110
4037
  let rules = mode === 'server' ? null : [];
@@ -3126,16 +4053,254 @@ ${state.unshown
3126
4053
  week,
3127
4054
  status: 400,
3128
4055
  notice: error.message,
4056
+ own,
3129
4057
  });
3130
4058
  }
3131
4059
  }
3132
- const changed = await options.resources.setHours(tenantOf(session), target === 'all' ? 'all' : { id: target.id }, rules, personOf(session));
3133
- if (changed === 0) {
3134
- return target === 'all'
3135
- ? resourcesPage(session, 409, 'Add a resource first, then set its hours.')
3136
- : notFound(session);
4060
+ const place = isLocationTarget(target) ? target.location : null;
4061
+ const changed = target === 'all'
4062
+ ? await options.resources.setHours(tenantOf(session), 'all', rules, personOf(session), overrideOf(form.get('override')))
4063
+ : await options.resources.setHours(tenantOf(session), place ? { location: place.ref } : { id: target.id }, rules, personOf(session));
4064
+ if (changed === 0 && target !== 'all')
4065
+ return notFound(session);
4066
+ return redirect(303, `${root}/${place ? 'locations' : 'resources'}`);
4067
+ }
4068
+ /** A subscription's address as the page names it: its host, never its path or query (which may hold a token). */
4069
+ const hostOfUrl = (url) => {
4070
+ try {
4071
+ return new URL(url).host;
3137
4072
  }
3138
- return redirect(303, `${root}/resources`);
4073
+ catch {
4074
+ return 'its address';
4075
+ }
4076
+ };
4077
+ /** The tenant's closures and special days from yesterday on, with forms to add and import more. */
4078
+ async function closuresPage(session, status = 200, notice) {
4079
+ const hours = options.resources.hours;
4080
+ if (!hours)
4081
+ return nothingHere(session);
4082
+ const tenantRef = tenantOf(session);
4083
+ const [exceptions, locations, resources, calendars] = await Promise.all([
4084
+ hours.exceptions(tenantRef, SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS + 1),
4085
+ options.resources.grouping?.locations(tenantRef) ?? Promise.resolve([]),
4086
+ options.resources.list(tenantRef),
4087
+ hours.calendars?.(tenantRef) ?? Promise.resolve([]),
4088
+ ]);
4089
+ const token = csrfToken(session);
4090
+ const sent = () => html `<input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}">`;
4091
+ const names = new Map(resources.map((resource) => [resource.id, resourceName(resource, resource.id)]));
4092
+ const places = new Map(locations.map((location) => [location.ref, location.name]));
4093
+ const scopeOf = (exception) => exception.resourceId
4094
+ ? names.get(exception.resourceId) ?? exception.resourceId
4095
+ : exception.location
4096
+ ? `${places.get(exception.location) ?? exception.location} (location)`
4097
+ : 'Everywhere';
4098
+ const days = (exception) => exception.startsOn === exception.endsOn ? exception.startsOn : `${exception.startsOn} to ${exception.endsOn}`;
4099
+ const what = (exception) => exception.outcome === 'closed'
4100
+ ? 'Closed'
4101
+ : `Open ${(exception.hours ?? []).map((window) => hoursOf(window.startMinutes, window.durationMinutes)).join(', ')}`;
4102
+ const scopeOptions = html `<option value="">Everywhere</option>${locations.map((location) => html `<option value="location:${location.ref}">${location.name} (location)</option>`)}${resources.slice(0, SLOTLOCK_DASHBOARD_MAX_RESOURCES).map((resource) => html `<option value="resource:${resource.id}">${resourceName(resource, resource.id)}</option>`)}`;
4103
+ const shown = exceptions.slice(0, SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS);
4104
+ const content = html `${noticeOf(notice)}
4105
+ <section>
4106
+ <p><a class="back" href="${root}/resources">Resources</a></p>
4107
+ <h1>Closures and special days</h1>
4108
+ <p>Each closes whole days, or gives them hours of their own instead of the weekly hours, read in each resource's time zone. Where several apply to a day, the nearest wins: a resource's own, then its location's, then everywhere's.</p>
4109
+ <div class="table"><table><thead><tr><th>Days</th><th>Applies to</th><th>Hours</th><th>Label</th><th></th></tr></thead><tbody>${shown.length === 0
4110
+ ? html `<tr><td colspan="5" class="muted">No closures from today on.</td></tr>`
4111
+ : shown.map((exception) => html `<tr><td data-label="Days">${days(exception)}</td><td data-label="Applies to">${scopeOf(exception)}</td><td data-label="Hours">${what(exception)}</td><td data-label="Label" class="wrap">${exception.label}${exception.source === 'ics' ? html ` <span class="meta">imported</span>` : html ``}</td><td class="actions"><form method="post" action="${root}/closures/delete">${sent()}<input type="hidden" name="id" value="${exception.id}"><button type="submit">Delete</button></form></td></tr>`)}</tbody></table></div>
4112
+ ${exceptions.length > SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS ? html `<p class="fine">The first ${String(SLOTLOCK_DASHBOARD_MAX_EXCEPTIONS)} are shown; the API lists them all.</p>` : html ``}
4113
+ <h2>Add a closure or special day</h2>
4114
+ <form method="post" action="${root}/closures" class="stack">${sent()}
4115
+ <label>Applies to <select name="scope">${scopeOptions}</select></label>
4116
+ <label>First day <input type="date" name="starts_on" required></label>
4117
+ <label>Last day <input type="date" name="ends_on"></label>
4118
+ <fieldset class="modes"><legend>Those days</legend>
4119
+ <label class="choice"><input type="radio" name="outcome" value="closed" checked> Closed</label>
4120
+ <label class="choice"><input type="radio" name="outcome" value="open"> Open only from <input type="time" name="opens" aria-label="Opens"> to <input type="time" name="closes" aria-label="Closes"></label>
4121
+ </fieldset>
4122
+ <label>Label <input name="label" maxlength="200" required placeholder="Christmas Day"></label>
4123
+ <button class="primary" type="submit">Add</button>
4124
+ </form>
4125
+ ${calendars.length > 0 ? html `<h2>Holiday calendars</h2>
4126
+ <div class="table"><table><thead><tr><th>Calendar</th><th>Applies to</th><th>From</th><th>Closures</th><th></th></tr></thead><tbody>${calendars.map((calendar) => html `<tr><td data-label="Calendar" class="wrap">${calendar.name}${calendar.lastError ? html `<span class="meta bad" role="status">The last import failed: ${calendar.lastError}</span>` : html ``}</td><td data-label="Applies to">${calendar.resourceId
4127
+ ? names.get(calendar.resourceId) ?? calendar.resourceId
4128
+ : calendar.location
4129
+ ? `${places.get(calendar.location) ?? calendar.location} (location)`
4130
+ : 'Everywhere'}</td><td data-label="From" class="wrap">${calendar.url
4131
+ ? html `${hostOfUrl(calendar.url)}<span class="meta">Imported again every 30 days${calendar.nextImportAt ? html `, next ${calendar.nextImportAt.toISOString().slice(0, 10)}` : html ``}</span>`
4132
+ : html `Uploaded or pasted${calendar.importedAt ? html `<span class="meta">${calendar.importedAt.toISOString().slice(0, 10)}</span>` : html ``}`}</td><td data-label="Closures">${String(calendar.closures)}</td><td class="actions">${hours.deleteCalendar
4133
+ ? html `<form method="post" action="${root}/closures/calendars/delete">${sent()}<input type="hidden" name="id" value="${calendar.id}"><button type="submit" class="danger">Delete</button></form>`
4134
+ : html ``}</td></tr>`)}</tbody></table></div>
4135
+ <p class="fine">Deleting a calendar deletes its closures too.</p>` : html ``}
4136
+ <h2>Import a holiday calendar</h2>
4137
+ <p>Upload or paste an iCalendar file of days off${hours.byUrl ? html `, or give its address` : html ``}: each all-day event closes its days for the next two years. A calendar is kept by its name where it applies: importing it again updates its closures and removes the holidays it no longer has.</p>
4138
+ <form method="post" action="${root}/closures/import" class="stack" enctype="multipart/form-data">${sent()}
4139
+ <label>Applies to <select name="scope">${scopeOptions}</select></label>
4140
+ <label>Name <input name="name" maxlength="200" placeholder="Its own name, else its address, else Holidays"></label>
4141
+ <label>File <input type="file" name="icalendar_file" accept=".ics,text/calendar"></label>
4142
+ <label>Or paste it <textarea name="icalendar" rows="6" placeholder="BEGIN:VCALENDAR"></textarea></label>
4143
+ ${hours.byUrl ? html `<label>Or its address <input type="url" name="url" maxlength="2000" placeholder="https://example.com/holidays.ics"></label>
4144
+ <label class="choice"><input type="checkbox" name="subscribe" value="on"> Import it again every 30 days</label>` : html ``}
4145
+ <button type="submit">Import</button>
4146
+ </form>
4147
+ </section>`;
4148
+ return respond(status, layout('Closures', content, session, 'resources'));
4149
+ }
4150
+ /**
4151
+ * The people the tenant keeps to tell about bookings (RFC 0004 P3): how to reach each, what they
4152
+ * agreed to and when, and a button that forgets one (their reference leaves every booking too).
4153
+ * Contacts are added by the tenant's own systems, through the API.
4154
+ */
4155
+ async function contactsPage(session, status = 200, notice, after) {
4156
+ const contacts = options.resources.contacts;
4157
+ if (!contacts)
4158
+ return nothingHere(session);
4159
+ const listed = await contacts.list(tenantOf(session), SLOTLOCK_DASHBOARD_CONTACTS_PAGE + 1, after);
4160
+ const shown = listed.slice(0, SLOTLOCK_DASHBOARD_CONTACTS_PAGE);
4161
+ const token = csrfToken(session);
4162
+ const reach = (contact) => [contact.email, contact.phone ? `SMS ${contact.phone}` : null, contact.whatsapp ? `WhatsApp ${contact.whatsapp}` : null]
4163
+ .filter(Boolean)
4164
+ .join(', ') || 'No way to reach them';
4165
+ const agreed = (contact) => ['email', 'sms', 'whatsapp']
4166
+ .filter((channel) => contact.consent[channel] !== undefined)
4167
+ .map((channel) => `${channel === 'sms' ? 'SMS' : channel === 'whatsapp' ? 'WhatsApp' : 'Email'}: ${contact.consent[channel] ? 'yes' : 'no'}${contact.consentAt[channel] ? ` (${contact.consentAt[channel]?.toISOString().slice(0, 10)})` : ''}`)
4168
+ .join('; ') || 'Not asked';
4169
+ const content = html `${noticeOf(notice)}
4170
+ <section>
4171
+ <p><a class="back" href="${root}/resources">Resources</a></p>
4172
+ <h1>Contacts</h1>
4173
+ <p>The people your bookings name to be told about them, kept by your own systems through the API (<code>PUT /v1/contacts/{contact_ref}</code>). Forgetting one deletes them and takes their reference off every booking, for an erasure request.</p>
4174
+ <table><thead><tr><th>Reference</th><th>Name</th><th>Reach by</th><th>Agreed to</th><th></th></tr></thead><tbody>${shown.length === 0
4175
+ ? html `<tr><td colspan="5" class="muted">No contacts yet.</td></tr>`
4176
+ : shown.map((contact) => html `<tr><td data-label="Reference"><code>${contact.ref}</code></td><td data-label="Name">${contact.name ?? ''}</td><td data-label="Reach by" class="wrap">${reach(contact)}</td><td data-label="Agreed to" class="wrap">${agreed(contact)}</td><td class="actions"><form method="post" action="${root}/contacts/forget"><input type="hidden" name="csrf" value="${token}"><input type="hidden" name="once" value="${formNonce(session)}"><input type="hidden" name="ref" value="${contact.ref}"><button type="submit" class="danger">Forget</button></form></td></tr>`)}</tbody></table>
4177
+ ${listed.length > SLOTLOCK_DASHBOARD_CONTACTS_PAGE ? html `<p><a class="button" href="${root}/contacts?after=${encodeURIComponent(shown.at(-1)?.ref ?? '')}">More</a></p>` : html ``}
4178
+ </section>`;
4179
+ return respond(status, layout('Contacts', content, session, 'resources'));
4180
+ }
4181
+ async function forgetContact(session, form) {
4182
+ const contacts = options.resources.contacts;
4183
+ if (!contacts)
4184
+ return nothingHere(session);
4185
+ const ref = form.get('ref') ?? '';
4186
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(ref))
4187
+ return contactsPage(session, 400, 'That is not a contact reference.');
4188
+ const forgotten = await contacts.forget(tenantOf(session), ref, personOf(session));
4189
+ if (!forgotten.deleted)
4190
+ return contactsPage(session, 404, 'That contact is not one of yours.');
4191
+ return contactsPage(session, 200, `Forgotten. ${forgotten.bookings} booking${forgotten.bookings === 1 ? '' : 's'} no longer name them.`);
4192
+ }
4193
+ /** `location:<ref>` or `resource:<id>` from a closures form, checked against the tenant's. */
4194
+ async function closureScope(session, value) {
4195
+ if (value === '')
4196
+ return {};
4197
+ if (value.startsWith('location:')) {
4198
+ const location = await ownLocation(session, value.slice(9));
4199
+ return location ? { location: location.ref } : null;
4200
+ }
4201
+ if (value.startsWith('resource:')) {
4202
+ const resource = await ownResource(session, value.slice(9));
4203
+ return resource ? { resourceId: resource.id } : null;
4204
+ }
4205
+ return null;
4206
+ }
4207
+ async function saveClosure(session, form) {
4208
+ const hours = options.resources.hours;
4209
+ if (!hours)
4210
+ return nothingHere(session);
4211
+ const scope = await closureScope(session, form.get('scope') ?? '');
4212
+ if (!scope)
4213
+ return closuresPage(session, 400, 'Choose where it applies.');
4214
+ const outcome = form.get('outcome') === 'open' ? 'open' : 'closed';
4215
+ const startsOn = form.get('starts_on') ?? '';
4216
+ const endsOn = form.get('ends_on') || startsOn;
4217
+ let windows;
4218
+ if (outcome === 'open') {
4219
+ const opens = minutesOf(form.get('opens') ?? '');
4220
+ const closes = minutesOf(form.get('closes') ?? '');
4221
+ if (opens === null || closes === null)
4222
+ return closuresPage(session, 400, 'Give the hours those days open.');
4223
+ windows = [{ startMinutes: opens, durationMinutes: closes > opens ? closes - opens : closes + 1_440 - opens }];
4224
+ }
4225
+ try {
4226
+ await hours.addException(tenantOf(session), {
4227
+ ...scope,
4228
+ startsOn,
4229
+ endsOn,
4230
+ outcome,
4231
+ ...(windows ? { hours: windows } : {}),
4232
+ label: form.get('label') ?? '',
4233
+ }, personOf(session));
4234
+ }
4235
+ catch (error) {
4236
+ const code = error.code;
4237
+ if (code === 'invalid_availability_exception' || code === 'availability_exceptions_limit_reached') {
4238
+ return closuresPage(session, code === 'invalid_availability_exception' ? 400 : 409, error.message);
4239
+ }
4240
+ throw error;
4241
+ }
4242
+ return redirect(303, `${root}/closures`);
4243
+ }
4244
+ async function deleteClosure(session, form) {
4245
+ const hours = options.resources.hours;
4246
+ if (!hours)
4247
+ return nothingHere(session);
4248
+ const id = form.get('id') ?? '';
4249
+ if (!UUID_PATTERN.test(id) || !(await hours.deleteException(tenantOf(session), id, personOf(session)))) {
4250
+ return closuresPage(session, 404, 'That closure is not one of yours.');
4251
+ }
4252
+ return redirect(303, `${root}/closures`);
4253
+ }
4254
+ async function importClosures(session, form) {
4255
+ const hours = options.resources.hours;
4256
+ if (!hours)
4257
+ return nothingHere(session);
4258
+ const scope = await closureScope(session, form.get('scope') ?? '');
4259
+ if (!scope)
4260
+ return closuresPage(session, 400, 'Choose where it applies.');
4261
+ // An uploaded file, else what was pasted, else (where the server reads them) an address.
4262
+ const text = (form.get('icalendar_file') ?? '').trim() || (form.get('icalendar') ?? '').trim();
4263
+ const url = hours.byUrl ? (form.get('url') ?? '').trim() : '';
4264
+ if ((text === '') === (url === '')) {
4265
+ return closuresPage(session, 400, hours.byUrl ? 'Upload or paste a calendar, or give its address: one of them.' : 'Upload or paste a calendar.');
4266
+ }
4267
+ const name = (form.get('name') ?? '').trim();
4268
+ const source = {
4269
+ ...(text !== '' ? { icalendar: text } : { url }),
4270
+ ...(name !== '' ? { name } : {}),
4271
+ ...(url !== '' && form.get('subscribe') === 'on' ? { subscribe: true } : {}),
4272
+ };
4273
+ try {
4274
+ const result = await hours.importHolidays(tenantOf(session), source, scope, personOf(session));
4275
+ const plural = (count, word) => `${String(count)} ${word}${count === 1 ? '' : 's'}`;
4276
+ return closuresPage(session, 200, `Imported ${plural(result.imported, 'closure')}${result.removed > 0 ? `; removed ${plural(result.removed, 'holiday')} the calendar no longer has` : ''}${result.skipped > 0 ? `; skipped ${plural(result.skipped, 'event')} that were not all-day or not in the next two years` : ''}.`);
4277
+ }
4278
+ catch (error) {
4279
+ const code = error.code;
4280
+ if (code === 'invalid_holiday_calendar' ||
4281
+ code === 'invalid_availability_exception' ||
4282
+ code === 'holiday_calendar_unreachable' ||
4283
+ code === 'holiday_url_not_served' ||
4284
+ code === 'location_not_found' ||
4285
+ code === 'resource_not_found') {
4286
+ return closuresPage(session, 400, error.message);
4287
+ }
4288
+ if (code === 'availability_exceptions_limit_reached' || code === 'holiday_calendars_limit_reached') {
4289
+ return closuresPage(session, 409, error.message);
4290
+ }
4291
+ throw error;
4292
+ }
4293
+ }
4294
+ /** Delete a holiday calendar and the closures it made. */
4295
+ async function deleteHolidayCalendar(session, form) {
4296
+ const hours = options.resources.hours;
4297
+ if (!hours?.deleteCalendar)
4298
+ return nothingHere(session);
4299
+ const id = form.get('id') ?? '';
4300
+ if (!(await hours.deleteCalendar(tenantOf(session), id, personOf(session)))) {
4301
+ return closuresPage(session, 404, 'That calendar is not one of yours.');
4302
+ }
4303
+ return redirect(303, `${root}/closures`);
3139
4304
  }
3140
4305
  function shownOncePage(session, key, apiKey, rotated) {
3141
4306
  const org = session.orgs?.find((candidate) => candidate.id === session.org);
@@ -3188,7 +4353,7 @@ ${org ? html `<p>For the organization <strong>${org.login}</strong>.</p>` : html
3188
4353
  : html `<span class="muted">An owner can turn it on</span>`;
3189
4354
  return html `<tr><td data-label="Organization" class="strong">${org.login}</td><td data-label="You are">${owner ? 'Owner' : 'Member'}</td><td data-label="Slotlock"><span class="pill ${on ? 'active' : 'revoked'}">${on ? 'On' : 'Off'}</span></td><td class="actions">${actions}</td></tr>`;
3190
4355
  });
3191
- const content = html `${noticeOf(notice)}${pageHead('Teams')}
4356
+ const content = html `${noticeOf(notice, status)}${pageHead('Teams')}
3192
4357
  <p>Work with the people in a GitHub organization: once an owner turns Slotlock on for it, every owner and member shares its resources, keys and history, and picks it from the workspace menu. Your own workspace stays yours.</p>
3193
4358
  ${rows.length === 0
3194
4359
  ? emptyState(ICONS.teams, 'Slotlock sees none of your GitHub organizations. It reads them as you sign in: after joining one, sign out and in again. An organization that restricts OAuth apps needs an owner to approve this one.')
@@ -3586,9 +4751,11 @@ ${rows.length === 0
3586
4751
  ]);
3587
4752
  }
3588
4753
  /** The form, `'too_large'` past MAX_FORM_BYTES (read no further), or null for another type. */
3589
- async function readForm(request, maxBytes = MAX_FORM_BYTES) {
3590
- const type = request.headers.get('content-type')?.split(';')[0]?.trim();
3591
- if (type !== 'application/x-www-form-urlencoded')
4754
+ async function readForm(request, maxBytes = MAX_FORM_BYTES, multipart = false) {
4755
+ const header = request.headers.get('content-type') ?? '';
4756
+ const type = header.split(';')[0]?.trim();
4757
+ const isMultipart = multipart && type === 'multipart/form-data';
4758
+ if (type !== 'application/x-www-form-urlencoded' && !isMultipart)
3592
4759
  return null;
3593
4760
  const declared = Number(request.headers.get('content-length'));
3594
4761
  if (Number.isFinite(declared) && declared > maxBytes)
@@ -3609,7 +4776,24 @@ ${rows.length === 0
3609
4776
  }
3610
4777
  chunks.push(value);
3611
4778
  }
3612
- return new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
4779
+ if (!isMultipart)
4780
+ return new URLSearchParams(Buffer.concat(chunks).toString('utf8'));
4781
+ // A file field is read as text under its name; nothing else a form sends is kept apart.
4782
+ let data;
4783
+ try {
4784
+ data = await new Response(Buffer.concat(chunks), { headers: { 'content-type': header } }).formData();
4785
+ }
4786
+ catch {
4787
+ return null;
4788
+ }
4789
+ const form = new URLSearchParams();
4790
+ for (const [name, value] of data.entries()) {
4791
+ if (typeof value === 'string')
4792
+ form.append(name, value);
4793
+ else if (value.size > 0)
4794
+ form.append(name, await value.text());
4795
+ }
4796
+ return form;
3613
4797
  }
3614
4798
  async function createKey(session, form) {
3615
4799
  const scopes = KEY_ACCESS[form.get('access') ?? ''];
@@ -3632,9 +4816,9 @@ ${rows.length === 0
3632
4816
  }
3633
4817
  // Keys limited to part of the tenant come with Developer (RFC 0003 pricing); a server without
3634
4818
  // plans allows them.
3635
- const plan = options.billing ? await options.billing.plan(tenantOf(session)) : null;
4819
+ const plan = plans ? await plans.plan(tenantOf(session)) : null;
3636
4820
  if (plan?.plan === 'free') {
3637
- return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See Plan.');
4821
+ return keysPage(session, 403, 'Keys limited to locations or groups come with the Developer and Team plans. See Usage.');
3638
4822
  }
3639
4823
  }
3640
4824
  try {
@@ -3659,8 +4843,8 @@ ${rows.length === 0
3659
4843
  return keysPage(session, 400, 'Choose up to 20 locations and 20 groups from the lists.');
3660
4844
  }
3661
4845
  if (code === 'api_key_limit_reached') {
3662
- return keysPage(session, 409, options.billing
3663
- ? 'You have as many active keys as your plan allows. Revoke one first, or see Plan.'
4846
+ return keysPage(session, 409, plans
4847
+ ? 'You have as many active keys as your plan allows. Revoke one first, or see Usage.'
3664
4848
  : 'You have as many keys as Slotlock allows. Revoke one first.');
3665
4849
  }
3666
4850
  throw error;
@@ -3735,7 +4919,7 @@ ${rows.length === 0
3735
4919
  if (code === 'resource_limit_reached') {
3736
4920
  const limit = error.limit;
3737
4921
  return resourcesPage(session, 409, typeof limit === 'number' && limit < SLOTLOCK_DASHBOARD_MAX_RESOURCES
3738
- ? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see Plan.`
4922
+ ? `You have ${String(limit)} resources, archived ones included, as many as your plan allows. Delete one, or see Usage.`
3739
4923
  : `You have ${SLOTLOCK_DASHBOARD_MAX_RESOURCES} resources, as many as the dashboard allows.`);
3740
4924
  }
3741
4925
  if (code === 'invalid_timezone') {
@@ -3756,7 +4940,13 @@ ${rows.length === 0
3756
4940
  const session = await readSession(request);
3757
4941
  if (!session)
3758
4942
  return redirect(303, root);
3759
- const form = await readForm(request, route === '/assistant' ? MAX_ASSISTANT_FORM_BYTES : MAX_FORM_BYTES);
4943
+ // A holiday calendar may be uploaded, or pasted, up to its own limit.
4944
+ const importing = route === '/closures/import';
4945
+ const form = await readForm(request, importing
4946
+ ? MAX_IMPORT_FORM_BYTES
4947
+ : route === '/assistant' || route === '/assistant/stream'
4948
+ ? MAX_ASSISTANT_FORM_BYTES
4949
+ : MAX_FORM_BYTES, importing);
3760
4950
  if (form === 'too_large') {
3761
4951
  return respond(413, layout('Refused', html `<p class="notice">That form is too large.</p>`, session));
3762
4952
  }
@@ -3794,12 +4984,17 @@ ${rows.length === 0
3794
4984
  return pageFor(route)(session, 429, 'Too many forms sent from this account in the last 12 hours. Try again later.');
3795
4985
  }
3796
4986
  if (!first) {
4987
+ // The first one was taken: a second click, a reload or a resend shows what it did.
4988
+ if (route.startsWith('/assistant')) {
4989
+ const chat = chatOf(form.get('chat'));
4990
+ return redirect(303, chat ? `${chatUrl(chat)}#latest` : `${root}/assistant#latest`);
4991
+ }
3797
4992
  return pageFor(route)(session, 409, 'That form was already sent. Reload the page to start again.');
3798
4993
  }
3799
4994
  }
3800
4995
  // A tenant new to plans is put on the default plan before it adds what plans limit.
3801
4996
  if (route === '/keys' || route === '/resources' || route === '/webhooks') {
3802
- await options.billing?.enrol?.(tenantOf(session));
4997
+ await plans?.enrol?.(tenantOf(session));
3803
4998
  }
3804
4999
  switch (route) {
3805
5000
  case '/sign-out':
@@ -3817,6 +5012,16 @@ ${rows.length === 0
3817
5012
  return addResource(session, form);
3818
5013
  case '/hours':
3819
5014
  return saveHours(session, form);
5015
+ case '/closures':
5016
+ return saveClosure(session, form);
5017
+ case '/closures/delete':
5018
+ return deleteClosure(session, form);
5019
+ case '/closures/import':
5020
+ return importClosures(session, form);
5021
+ case '/closures/calendars/delete':
5022
+ return deleteHolidayCalendar(session, form);
5023
+ case '/contacts/forget':
5024
+ return forgetContact(session, form);
3820
5025
  case '/organization':
3821
5026
  return switchOrganization(session, form);
3822
5027
  case '/environment':
@@ -3857,12 +5062,18 @@ ${rows.length === 0
3857
5062
  return sandboxAction(session, form, 'fast-forward');
3858
5063
  case '/assistant':
3859
5064
  return sendToAssistant(session, form);
5065
+ case '/assistant/stream':
5066
+ return streamToAssistant(session, form, request);
3860
5067
  case '/assistant/approve':
3861
5068
  return settleAction(session, form, 'approve');
3862
5069
  case '/assistant/dismiss':
3863
5070
  return settleAction(session, form, 'dismiss');
3864
5071
  case '/assistant/reset':
3865
5072
  return resetAssistant(session);
5073
+ case '/assistant/rename':
5074
+ return renameChat(session, form);
5075
+ case '/assistant/delete':
5076
+ return deleteChat(session, form);
3866
5077
  case '/settings/assistant':
3867
5078
  return saveAssistantSettings(session, form);
3868
5079
  case '/settings/assistant/models':
@@ -3904,8 +5115,12 @@ ${rows.length === 0
3904
5115
  case '/waitlist':
3905
5116
  case '/approvals':
3906
5117
  case '/assistant':
5118
+ case '/assistant/chats':
3907
5119
  case '/plan':
3908
5120
  case '/teams':
5121
+ case '/settings/endpoints':
5122
+ case '/settings/hours':
5123
+ case '/settings/account':
3909
5124
  case '/settings': {
3910
5125
  const session = await readSession(request);
3911
5126
  if (!session)
@@ -3919,8 +5134,9 @@ ${rows.length === 0
3919
5134
  }
3920
5135
  if (route === '/keys')
3921
5136
  return await keysPage(session);
3922
- if (route === '/resources')
3923
- return await resourcesPage(session);
5137
+ if (route === '/resources') {
5138
+ return await resourcesPage(session, 200, undefined, url.searchParams.get('hours') === 'own');
5139
+ }
3924
5140
  if (route === '/webhooks')
3925
5141
  return await webhooksPage(session);
3926
5142
  if (route === '/waitlist')
@@ -3928,15 +5144,23 @@ ${rows.length === 0
3928
5144
  if (route === '/approvals')
3929
5145
  return await approvalsPage(session);
3930
5146
  if (route === '/assistant')
3931
- return await assistantPage(session);
5147
+ return await assistantPage(session, 200, undefined, url.searchParams.get('c') ?? undefined);
5148
+ if (route === '/assistant/chats')
5149
+ return await chatsPage(session);
5150
+ if (route === '/settings/endpoints')
5151
+ return await settingsPage(session, 200, undefined, undefined, 'endpoints');
5152
+ if (route === '/settings/hours')
5153
+ return await settingsPage(session, 200, undefined, undefined, 'hours');
5154
+ if (route === '/settings/account')
5155
+ return await settingsPage(session, 200, undefined, undefined, 'account');
3932
5156
  // A provider preset fills the assistant's form; nothing is saved until Save.
3933
5157
  const preset = SLOTLOCK_ASSISTANT_PRESETS.find((candidate) => candidate.id === url.searchParams.get('preset'));
3934
5158
  return await settingsPage(session, 200, undefined, preset ? { provider: preset.provider, baseUrl: preset.baseUrl } : undefined);
3935
5159
  }
3936
5160
  case '/style.css':
3937
- return asset(DASHBOARD_CSS, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
5161
+ return asset(DASHBOARD_ASSETS.css, 'text/css; charset=utf-8', url, ASSET_VERSIONS.css);
3938
5162
  case '/app.js':
3939
- return asset(DASHBOARD_JS, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
5163
+ return asset(DASHBOARD_ASSETS.js, 'text/javascript; charset=utf-8', url, ASSET_VERSIONS.js);
3940
5164
  case '/favicon.svg':
3941
5165
  return asset(SLOTLOCK_DASHBOARD_ICON_SVG, 'image/svg+xml', url, ASSET_VERSIONS.icon);
3942
5166
  case '/sign-in':
@@ -3949,6 +5173,15 @@ ${rows.length === 0
3949
5173
  const session = await readSession(request);
3950
5174
  return session ? await hoursEditor(session, url) : redirect(303, root);
3951
5175
  }
5176
+ case '/closures': {
5177
+ const session = await readSession(request);
5178
+ return session ? await closuresPage(session) : redirect(303, root);
5179
+ }
5180
+ case '/contacts': {
5181
+ const session = await readSession(request);
5182
+ const after = url.searchParams.get('after') ?? undefined;
5183
+ return session ? await contactsPage(session, 200, undefined, after) : redirect(303, root);
5184
+ }
3952
5185
  case '/locations': {
3953
5186
  const session = await readSession(request);
3954
5187
  return session ? await locationsPage(session) : redirect(303, root);
@@ -4140,13 +5373,44 @@ a.stat:hover { background: var(--surface-2); text-decoration: none; }
4140
5373
  a.stat::after { content: ""; position: absolute; top: 18px; right: 18px; width: 6px; height: 6px; border-top: 1.5px solid var(--ink-3); border-right: 1.5px solid var(--ink-3); transform: rotate(45deg); opacity: 0; transition: opacity var(--fast) var(--ease); }
4141
5374
  a.stat:hover::after, a.stat:focus-visible::after { opacity: 1; }
4142
5375
  a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
4143
- .stat-label { font: 500 var(--text-xs)/1.4 var(--mono); color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; }
5376
+ .stat-label { font-size: var(--text-sm); line-height: 1.4; color: var(--ink-3); }
5377
+ .attention-stat .stat-value { color: var(--accent); }
4144
5378
  .stat-value { font-size: var(--text-2xl); line-height: 1.1; font-weight: 500; letter-spacing: -0.03em; font-variant-numeric: tabular-nums; }
4145
5379
 
4146
- /* Setup checklist */
4147
- .setup .steps { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; counter-reset: step; }
4148
- .steps li { display: flex; align-items: center; gap: var(--space-3); min-height: 36px; color: var(--ink); font-weight: 500; }
4149
- .steps li.done { color: var(--ink-3); font-weight: 400; text-decoration: line-through; text-decoration-color: var(--line-strong); }
5380
+ /* Overview: what needs a person, getting started, the side column */
5381
+ .column { display: grid; gap: var(--space-5); align-content: start; min-width: 0; }
5382
+ .panel-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-3); }
5383
+ .panel-head h2 { margin: 0; }
5384
+ .panel-head .meta { flex: none; }
5385
+ .attention-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
5386
+ .attention { display: flex; align-items: center; gap: var(--space-4); padding: 10px var(--space-4); border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); font-size: var(--text-md); color: var(--ink); box-shadow: inset 3px 0 0 var(--ink-3); }
5387
+ .attention.bad { border-color: var(--bad-line); background: var(--bad-wash); box-shadow: inset 3px 0 0 var(--bad); }
5388
+ .attention.warn { border-color: var(--warn-line); background: var(--warn-wash); box-shadow: inset 3px 0 0 var(--warn); }
5389
+ .attention.info { box-shadow: inset 3px 0 0 var(--accent); }
5390
+ .attention-text { flex: 1; min-width: 0; }
5391
+ .attention-action { flex: none; font-weight: 600; white-space: nowrap; }
5392
+ .segments { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); gap: 4px; }
5393
+ .segments span { height: 4px; border-radius: var(--radius-full); background: var(--sunken); }
5394
+ .segments span.done { background: var(--good); }
5395
+ .setup-card .segments { margin-bottom: var(--space-3); }
5396
+ .setup-steps { list-style: none; margin: 0; padding: 0; display: grid; }
5397
+ .setup-steps li { display: flex; align-items: center; gap: var(--space-3); min-height: 44px; border-top: 1px solid var(--line-soft); color: var(--ink-3); }
5398
+ .setup-steps li:first-child { border-top: 0; }
5399
+ .setup-steps li.next { color: var(--ink); font-weight: 500; }
5400
+ .setup-steps li.done .step-title { text-decoration: line-through; text-decoration-color: var(--line-strong); }
5401
+ .setup-steps .step-title { flex: 1; min-width: 0; }
5402
+ .setup-row { display: flex; align-items: center; gap: var(--space-4); padding: 10px var(--space-4); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); color: var(--ink-2); font-size: var(--text-md); }
5403
+ .setup-row:hover { background: var(--surface-2); text-decoration: none; }
5404
+ .setup-row .segments { width: 96px; flex: none; }
5405
+ .plan-card .panel-head h2 { font-size: var(--text-lg); }
5406
+ .meters.stacked { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); margin: 0 0 var(--space-4); }
5407
+ .plan-card > .button { width: 100%; justify-content: center; }
5408
+ details.command { margin-top: var(--space-4); }
5409
+ details.command > summary { cursor: pointer; font-size: var(--text-md); color: var(--ink-2); }
5410
+ details.command[open] > summary { margin-bottom: var(--space-2); }
5411
+ .copy-block { position: relative; }
5412
+ .copy-block pre { padding-right: 72px; font-size: var(--text-sm); }
5413
+ .copy-block button { position: absolute; top: 6px; right: 6px; }
4150
5414
  .check { display: inline-grid; place-items: center; width: 20px; height: 20px; flex: none; border-radius: var(--radius-full); border: 1.5px dashed var(--line-strong); color: var(--surface); }
4151
5415
  .done .check { background: var(--good); border: 0; }
4152
5416
  .check .icon { width: 12px; height: 12px; stroke-width: 3; }
@@ -4164,7 +5428,11 @@ a.stat:focus-visible { outline-offset: -2px; border-radius: 0; }
4164
5428
  .on { color: var(--ink-3); font-weight: 400; }
4165
5429
  .meta { font-size: var(--text-sm); color: var(--ink-3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
4166
5430
  .feed { position: relative; }
4167
- .event { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr); gap: var(--space-3); align-items: start; padding: 7px 0; }
5431
+ .event { position: relative; display: grid; grid-template-columns: 12px minmax(0, 1fr) auto; gap: var(--space-3); align-items: start; padding: 7px 0; }
5432
+ .event .title { font-weight: 400; color: var(--ink-2); white-space: normal; overflow-wrap: anywhere; }
5433
+ .event .title strong { font-weight: 600; color: var(--ink); }
5434
+ .event .when { font-size: var(--text-sm); font-weight: 400; color: var(--ink-3); white-space: nowrap; padding-top: 1px; }
5435
+ .times { font-size: var(--text-sm); color: var(--ink-3); }
4168
5436
  .event:not(:last-child)::before { content: ""; position: absolute; left: 5.5px; top: 22px; bottom: -8px; width: 1px; background: var(--line); }
4169
5437
  .dot { position: relative; width: 12px; height: 12px; margin-top: 4px; border-radius: var(--radius-full); background: var(--surface); box-shadow: inset 0 0 0 3px var(--ink-3); }
4170
5438
  .dot.good { box-shadow: inset 0 0 0 3px var(--good); }
@@ -4204,8 +5472,6 @@ textarea { padding: 10px var(--space-3); resize: vertical; min-height: 72px; lin
4204
5472
  .create .grow { flex: 1 1 220px; }
4205
5473
  label > input, label > select, label > .unit-input { width: 100%; }
4206
5474
  label > textarea { width: 100%; padding: var(--space-2) var(--space-3); resize: vertical; }
4207
- .presets { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2); margin: 0 0 var(--space-3); }
4208
- .presets button { height: 28px; padding: 0 var(--space-3); font-size: var(--text-sm); }
4209
5475
  label.choice > input { width: 16px; }
4210
5476
  .create label:not(.grow) { flex: 0 1 auto; }
4211
5477
  form > label { margin-bottom: var(--space-3); }
@@ -4295,6 +5561,22 @@ form > .table { margin-bottom: var(--space-3); }
4295
5561
  /* Notices and empty states */
4296
5562
  .notice { display: block; border: 1px solid var(--line); border-radius: var(--radius); padding: var(--space-3) var(--space-4); background: var(--surface); color: var(--ink); margin: 0; font-size: var(--text-md); }
4297
5563
  .notice[role="alert"] { border-color: var(--bad-line); background: var(--bad-wash); color: var(--ink); box-shadow: inset 3px 0 0 var(--bad); }
5564
+ .meters { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-5) var(--space-6); margin: var(--space-4) 0; }
5565
+ .meter { display: grid; gap: 6px; min-width: 0; }
5566
+ .meter-head { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-2); font-size: var(--text-md); color: var(--ink-2); }
5567
+ .meter-value { color: var(--ink); font-weight: 500; font-variant-numeric: tabular-nums; }
5568
+ .meter progress { appearance: none; -webkit-appearance: none; width: 100%; height: 6px; border: 0; border-radius: var(--radius-full); background: var(--sunken); overflow: hidden; }
5569
+ .meter progress::-webkit-progress-bar { background: var(--sunken); border-radius: var(--radius-full); }
5570
+ .meter progress::-webkit-progress-value { background: var(--ink-2); border-radius: var(--radius-full); }
5571
+ .meter progress::-moz-progress-bar { background: var(--ink-2); border-radius: var(--radius-full); }
5572
+ .meter.warn progress::-webkit-progress-value { background: var(--warn); }
5573
+ .meter.warn progress::-moz-progress-bar { background: var(--warn); }
5574
+ .meter.bad progress::-webkit-progress-value { background: var(--bad); }
5575
+ .meter.bad progress::-moz-progress-bar { background: var(--bad); }
5576
+ .meter.bad .meter-value { color: var(--bad); }
5577
+ .plan-head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); }
5578
+ .plan-head h2 { margin: 0; }
5579
+ .notice.done { border-color: color-mix(in srgb, var(--good) 35%, var(--line)); background: var(--good-wash); color: var(--ink); box-shadow: inset 3px 0 0 var(--good); }
4298
5580
  .notice.warn, .notice.warn[role="alert"] { border-color: var(--warn-line); background: var(--warn-wash); color: var(--warn); box-shadow: inset 3px 0 0 var(--warn); }
4299
5581
  .notice p { color: inherit; margin: 0 0 var(--space-2); }
4300
5582
  .empty { color: var(--ink-3); margin: 0; }
@@ -4331,23 +5613,6 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
4331
5613
  .bare-main > .notice { max-width: 480px; }
4332
5614
 
4333
5615
  /* Assistant */
4334
- .chat { display: grid; gap: var(--space-5); }
4335
- .thread { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3); }
4336
- .msg { max-width: 78%; padding: 10px 14px; border-radius: 14px; background: var(--surface-2); border: 1px solid var(--line); }
4337
- .msg p { margin: 0; color: var(--ink); white-space: pre-wrap; overflow-wrap: anywhere; }
4338
- .msg.mine { justify-self: end; background: var(--button); border-color: var(--button); border-bottom-right-radius: 4px; }
4339
- .msg.mine p { color: var(--button-ink); }
4340
- .msg.bot { justify-self: start; border-bottom-left-radius: 4px; }
4341
- .tool-line { font: 400 var(--text-sm)/1.5 var(--mono); color: var(--ink-3); padding-left: var(--space-1); }
4342
- .thread > .notice { justify-self: start; }
4343
- .action-card { justify-self: start; width: min(100%, 540px); padding: var(--space-4) var(--space-5); border: 1px solid var(--line); border-radius: var(--radius-lg); background: var(--surface); box-shadow: var(--pop-shadow); display: grid; gap: var(--space-3); }
4344
- .action-head { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); }
4345
- .action-card .facts { font-size: var(--text-md); gap: 6px var(--space-4); }
4346
- .action-card .facts dt::first-letter { text-transform: uppercase; }
4347
- .action-card pre.wrap { white-space: pre-wrap; margin: 0; padding: var(--space-2); font-size: 12px; }
4348
- .action-summary { margin: 0; color: var(--ink); font-weight: 500; overflow-wrap: anywhere; }
4349
- .action-card .action-buttons { margin-top: var(--space-1); }
4350
- .composer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--space-3); align-items: end; margin: 0; padding-top: var(--space-4); border-top: 1px solid var(--line-soft); }
4351
5616
 
4352
5617
  @media (max-width: 1080px) {
4353
5618
  .split { grid-template-columns: minmax(0, 1fr); }
@@ -4377,6 +5642,8 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
4377
5642
  .stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
4378
5643
  .stat { padding: 14px var(--space-4); box-shadow: -1px 0 0 var(--line), 0 -1px 0 var(--line); }
4379
5644
  .stat-value { font-size: 22px; }
5645
+ .attention { flex-wrap: wrap; gap: var(--space-1) var(--space-4); }
5646
+ .setup-row .segments { width: 56px; }
4380
5647
  .create > * { flex: 1 1 100%; }
4381
5648
  .create button { width: 100%; }
4382
5649
  .endpoints { grid-template-columns: minmax(0, 1fr); gap: var(--space-1); }
@@ -4405,8 +5672,6 @@ pre { background: var(--surface-2); border: 1px solid var(--line); border-radius
4405
5672
  .act.danger summary { border-color: var(--bad-line); }
4406
5673
  .pop { left: 0; right: auto; width: min(300px, calc(100vw - 48px)); transform-origin: top left; }
4407
5674
  .table .pop { margin-left: 0; }
4408
- .msg { max-width: 92%; }
4409
- .composer { grid-template-columns: minmax(0, 1fr); }
4410
5675
  .empty-state { padding: var(--space-8) var(--space-4); }
4411
5676
  .bare-main { padding-top: 6vh; }
4412
5677
  .sign-in { padding: var(--space-6); }
@@ -4425,10 +5690,15 @@ const COMPOSITE_PROBLEMS = Object.freeze({
4425
5690
  composite_parts_limit: 'A whole has at most 50 parts.',
4426
5691
  composite_busy: 'One of them has a booking or hold still to come. A composite changes only while neither does.',
4427
5692
  });
4428
- /** What the Plan page calls each plan. */
5693
+ /** What the Usage page calls each plan. */
4429
5694
  const PLAN_NAMES = Object.freeze({ free: 'Free', developer: 'Developer', team: 'Team' });
5695
+ const DASHBOARD_ASSETS = Object.freeze({
5696
+ css: DASHBOARD_CSS + SLOTLOCK_ASSISTANT_CSS + SLOTLOCK_SETTINGS_CSS,
5697
+ // The chat's script renders a reply with the server's own Markdown renderer, embedded.
5698
+ js: DASHBOARD_JS + slotlockAssistantScript(renderSlotlockMarkdown.toString()),
5699
+ });
4430
5700
  const ASSET_VERSIONS = Object.freeze({
4431
- css: createHash('sha256').update(DASHBOARD_CSS).digest('base64url').slice(0, 12),
4432
- js: createHash('sha256').update(DASHBOARD_JS).digest('base64url').slice(0, 12),
5701
+ css: createHash('sha256').update(DASHBOARD_ASSETS.css).digest('base64url').slice(0, 12),
5702
+ js: createHash('sha256').update(DASHBOARD_ASSETS.js).digest('base64url').slice(0, 12),
4433
5703
  icon: createHash('sha256').update(SLOTLOCK_DASHBOARD_ICON_SVG).digest('base64url').slice(0, 12),
4434
5704
  });