domma-cms 1.6.37 → 1.6.39

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 (82) hide show
  1. package/admin/dist/domma/domma-tools.css +3 -3
  2. package/admin/dist/domma/domma-tools.min.js +3 -3
  3. package/package.json +2 -2
  4. package/plugins/_lib/admin/mail/mail.css +1 -1
  5. package/plugins/_lib/admin/mail/oauth-panel.js +303 -0
  6. package/plugins/_lib/admin/mail/reader-view.js +126 -13
  7. package/plugins/_lib/mail/accounts.js +548 -225
  8. package/plugins/_lib/mail/imapPool.js +63 -13
  9. package/plugins/_lib/mail/oauth.js +453 -0
  10. package/plugins/_lib/mail/oauthRoutes.js +294 -0
  11. package/plugins/_lib/mail/readRoutes.js +23 -2
  12. package/plugins/_lib/mail/scheduler.js +30 -6
  13. package/plugins/blog/CLAUDE.md +11 -1
  14. package/plugins/blog/lib/cache.js +43 -0
  15. package/plugins/blog/lib/page.js +28 -9
  16. package/plugins/blog/plugin.js +14 -13
  17. package/plugins/blog/plugin.json +1 -1
  18. package/plugins/blog/plugin.public.js +1 -1
  19. package/plugins/blog/tests/cache.test.js +65 -0
  20. package/plugins/booking/CLAUDE.md +14 -2
  21. package/plugins/booking/admin/lib/diary-layout.js +18 -0
  22. package/plugins/booking/admin/views/diary.js +153 -152
  23. package/plugins/booking/admin/views/people.js +64 -66
  24. package/plugins/booking/admin/views/settings.js +31 -32
  25. package/plugins/booking/plugin.json +1 -1
  26. package/plugins/booking/tests/admin-diary.test.js +9 -1
  27. package/plugins/feedback/CLAUDE.md +62 -0
  28. package/plugins/feedback/admin/css/index.css +16 -1
  29. package/plugins/feedback/admin/lib/kit.js +106 -0
  30. package/plugins/feedback/admin/lib/settings.js +107 -0
  31. package/plugins/feedback/admin/views/feedback.js +4 -1
  32. package/plugins/feedback/admin/views/inbox.js +11 -2
  33. package/plugins/feedback/admin/views/send.js +74 -7
  34. package/plugins/feedback/docs/guide.md +9 -9
  35. package/plugins/feedback/lib/attachments.js +133 -0
  36. package/plugins/feedback/lib/cache.js +43 -0
  37. package/plugins/feedback/lib/receiver.js +36 -6
  38. package/plugins/feedback/lib/sender.js +89 -6
  39. package/plugins/feedback/lib/shape.js +111 -6
  40. package/plugins/feedback/plugin.js +173 -17
  41. package/plugins/feedback/plugin.json +2 -2
  42. package/plugins/feedback/tests/api.test.js +125 -3
  43. package/plugins/feedback/tests/settings.test.js +59 -0
  44. package/plugins/feedback/tests/shape.test.js +87 -1
  45. package/plugins/free-tier.lock.json +74 -62
  46. package/plugins/mail-reader/CLAUDE.md +15 -4
  47. package/plugins/mail-reader/plugin.json +1 -1
  48. package/plugins/shopping-cart/CLAUDE.md +35 -2
  49. package/plugins/shopping-cart/admin/css/index.css +3 -0
  50. package/plugins/shopping-cart/admin/views/orders.js +75 -23
  51. package/plugins/shopping-cart/admin/views/shop.js +1 -1
  52. package/plugins/shopping-cart/docs/guide.md +7 -3
  53. package/plugins/shopping-cart/lib/payments.js +15 -2
  54. package/plugins/shopping-cart/lib/refunds.js +51 -0
  55. package/plugins/shopping-cart/lib/sales.js +15 -8
  56. package/plugins/shopping-cart/lib/shop.js +38 -3
  57. package/plugins/shopping-cart/plugin.js +47 -19
  58. package/plugins/shopping-cart/plugin.json +2 -2
  59. package/plugins/shopping-cart/tests/api.test.js +52 -0
  60. package/plugins/shopping-cart/tests/commission.test.js +1 -1
  61. package/plugins/shopping-cart/tests/pure.test.js +16 -0
  62. package/plugins/shopping-cart/tests/refunds.test.js +42 -0
  63. package/plugins/support/CLAUDE.md +58 -0
  64. package/plugins/support/admin/css/index.css +16 -1
  65. package/plugins/support/admin/lib/kit.js +192 -8
  66. package/plugins/support/admin/lib/settings.js +123 -0
  67. package/plugins/support/admin/views/desk.js +55 -12
  68. package/plugins/support/admin/views/inbox.js +17 -4
  69. package/plugins/support/admin/views/support.js +4 -1
  70. package/plugins/support/docs/guide.md +7 -9
  71. package/plugins/support/lib/attachments.js +133 -0
  72. package/plugins/support/lib/cache.js +43 -0
  73. package/plugins/support/lib/receiver.js +45 -10
  74. package/plugins/support/lib/sender.js +96 -10
  75. package/plugins/support/lib/shape.js +153 -12
  76. package/plugins/support/plugin.js +159 -29
  77. package/plugins/support/plugin.json +2 -2
  78. package/plugins/support/tests/api.test.js +125 -3
  79. package/plugins/support/tests/settings.test.js +68 -0
  80. package/plugins/support/tests/shape.test.js +76 -2
  81. package/server/routes/outreach-public.js +2 -2
  82. package/server/services/request-log.js +3 -1
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Blog - the short memory behind the public pages and the badge (lib/cache.js),
3
+ * and the rule that matters: a write is seen by the very next read.
4
+ */
5
+ import {test} from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import Fastify from 'fastify';
8
+ import {createCache} from '../lib/cache.js';
9
+
10
+ test('a read is shared for the cache window, then read again', async () => {
11
+ let now = 0;
12
+ let reads = 0;
13
+ const c = createCache({ttl: 30_000, clock: () => now});
14
+ const load = async () => { reads++; return ['post']; };
15
+ const [a, b] = await Promise.all([c.get('posts', load), c.get('posts', load)]);
16
+ assert.equal(reads, 1, 'concurrent misses share one read');
17
+ assert.equal(a, b, 'the same array - callers must not change it in place');
18
+ now = 29_999;
19
+ await c.get('posts', load);
20
+ assert.equal(reads, 1);
21
+ now = 30_000;
22
+ await c.get('posts', load);
23
+ assert.equal(reads, 2, 'the window passed: read again');
24
+ });
25
+
26
+ test('forget() retires every key, and a read in flight when the write landed is not kept', async () => {
27
+ const c = createCache({ttl: 60_000});
28
+ let value = 'old';
29
+ let release;
30
+ const slow = () => new Promise(r => { release = r; });
31
+ const inFlight = c.get('posts', slow);
32
+ await c.get('categories', async () => ['cat']);
33
+ assert.equal(c.size, 2);
34
+ // The write finishes while the read is still on its way.
35
+ value = 'new';
36
+ c.forget();
37
+ release('old');
38
+ assert.equal(await inFlight, 'old', 'the caller who started before the write still gets what they read');
39
+ assert.equal(await c.get('posts', async () => value), 'new', 'the next read is fresh, not the stale one');
40
+ assert.deepEqual(await c.get('categories', async () => ['fresh']), ['fresh']);
41
+ });
42
+
43
+ test('a failed read is not remembered', async () => {
44
+ const c = createCache();
45
+ await assert.rejects(() => c.get('posts', async () => { throw new Error('adapter down'); }), /adapter down/);
46
+ assert.equal(c.size, 0);
47
+ assert.deepEqual(await c.get('posts', async () => ['ok']), ['ok']);
48
+ });
49
+
50
+ test('the plugin forgets on every write route, before the writer\'s response leaves', async () => {
51
+ // The hook as plugin.js registers it, on a stand-in app: the point under test
52
+ // is the method rule and that it fires in onSend (the writer's next read is fresh).
53
+ const c = createCache();
54
+ const app = Fastify();
55
+ app.addHook('onSend', async (request) => { if (request.method !== 'GET' && request.method !== 'HEAD') c.forget(); });
56
+ app.get('/posts', async () => c.get('posts', async () => ({at: Date.now()})));
57
+ app.put('/posts/1', async () => ({ok: true}));
58
+ await c.get('posts', async () => 'stale');
59
+ assert.equal(c.size, 1);
60
+ await app.inject({method: 'GET', url: '/posts'});
61
+ assert.equal(c.size, 1, 'a GET keeps the memory');
62
+ await app.inject({method: 'PUT', url: '/posts/1', payload: {}});
63
+ assert.equal(c.size, 0, 'a write retires it');
64
+ await app.close();
65
+ });
@@ -17,8 +17,8 @@ Calendar events block their times (read from `calendar-occurrences` / `calendar-
17
17
  service - no import of the Calendar, which is a separate paid Tool).
18
18
 
19
19
  `tier: free`, ships with the engine, disabled on arrival. Plan and phases: `dcms-marketplace/docs/booking-plan.md`.
20
- Booking Pro (not yet built) is an ADD-ON that registers into this one (`lib/extensions.js`), the Cart / Cart Pro
21
- pattern - it does not supersede it. Payment is Pro only.
20
+ Booking Pro (marketplace `booking-pro`, 0.4.x) is an ADD-ON that registers into this one (`lib/extensions.js`), the
21
+ Cart / Cart Pro pattern - it does not supersede it. Neither takes payment yet.
22
22
 
23
23
  1.6.0 (2026-09-30): "Add people" in an online booking's call (core with `server/services/callInvites.js` and
24
24
  `_lib/call/invite.js`; both imported dynamically - lib/call.js by top-level `await import`, plugin.js beside
@@ -168,3 +168,15 @@ only to roles that hold it.
168
168
  the run (restored after) and forges a Host to prove it.
169
169
  - The API tests create contacts in domma-cms's `content/collections/contacts-contacts/data.json` (tracked) - they
170
170
  snapshot and restore it; a crashed run can leave it changed: `git checkout` it in domma-cms.
171
+
172
+ ## 1.6.4 (2026-10-13 Tool sweep, upgrades)
173
+ - The Diary is domma-reactive end to end: heads, hour gutter and columns are keyed `data-each` lists (`HEAD`, `HOUR`,
174
+ `COL` in diary.js; each column's open bands and blocks are `{{#each}}` inside it). One `draw` effect works out every
175
+ measure as a CSS length (`blockRow`) and the markup only binds; the empty state, "add a service" tip and first-run
176
+ checklist (`STEP`) are bound too. Kebab / right-click / Shift+F10 still come from `wireRows(gridBox, {match: '.bk-block'})`
177
+ (delegated, so re-rendered blocks need no rewiring). `mergeWindows` moved to admin/lib/diary-layout.js (tested).
178
+ - Settings > Closed days (`CLOSURE_ROW`) and a person's opening hours / days off (`DAY_ROW` with a nested
179
+ `{{#each windows}}`, `OFF_ROW`) are keyed lists of per-field observables; `hoursOut` / `daysOffOut` / `closuresOut`
180
+ turn them back into plain data for the dirty snapshot and the save. A "from" after the "to" still pulls "to" along.
181
+ - Icons in rows added after binding are drawn by `I.scan` after a flush (`rescan`), because new rows arrive after
182
+ the panel's first scan.
@@ -138,3 +138,21 @@ export function columnItems(bookings, {bookableId = ''} = {}) {
138
138
  }
139
139
  return [...groups.values()].map(g => ({...g, session: g.bookings.length > 1}));
140
140
  }
141
+
142
+ /**
143
+ * Opening windows (minutes) merged where they touch or overlap - the week view's one band per day for everyone.
144
+ * Moved here from diary.js (1.6.4) so it is tested with the rest of the geometry.
145
+ *
146
+ * @param {{from: number, to: number}[]} list
147
+ * @returns {{from: number, to: number}[]} sorted, non-overlapping copies
148
+ */
149
+ export function mergeWindows(list) {
150
+ const sorted = [...(list || [])].sort((a, b) => a.from - b.from);
151
+ const out = [];
152
+ for (const w of sorted) {
153
+ const last = out[out.length - 1];
154
+ if (last && w.from <= last.to) last.to = Math.max(last.to, w.to);
155
+ else out.push({...w});
156
+ }
157
+ return out;
158
+ }
@@ -6,10 +6,14 @@
6
6
  * is shaded. A booking is a block: click opens it, right-click / ⋮ /
7
7
  * Shift+F10 is its menu. Click an empty time to book it. The first time
8
8
  * round, a checklist says what to set up.
9
+ *
10
+ * domma-reactive throughout: the heads, the hour gutter and the columns (each
11
+ * with its open bands and blocks) are keyed lists fed from one `draw` effect
12
+ * that works the geometry out in JS (diary-layout.js); the markup only binds.
9
13
  */
10
14
 
11
15
  import {STATUS_LABEL, V, api, copy, errText, esc, openPanel, store, wireRows} from '../lib/kit.js';
12
- import {columnItems, dayLabel, datesFor, gridHours, layoutColumn, minuteAt, placeOf} from '../lib/diary-layout.js';
16
+ import {columnItems, dayLabel, datesFor, gridHours, layoutColumn, mergeWindows, minuteAt, placeOf} from '../lib/diary-layout.js';
13
17
  import {withNames} from '../lib/details.js';
14
18
  import {addDays, atWallClock, fromMinutes, isDate, wallClock} from '/plugins/booking/public/lib/time.js';
15
19
  import {windowsOn} from '/plugins/booking/public/lib/slots.js';
@@ -18,6 +22,37 @@ const PX_PER_MIN = 1; // an hour is 60px
18
22
  const VIEW_KEY = 'booking.diary-view';
19
23
  const CANCELLED_KEY = 'booking.diary-cancelled';
20
24
  const CHECKLIST_KEY = 'booking.checklist-hidden';
25
+ const STATUS_NOTE = {completed: 'done', 'no-show': 'no-show', cancelled: 'cancelled', pending: 'pending'};
26
+
27
+ // A first-run step. The two icon wrappers swap with `done` (I.scan replaces the icon span itself).
28
+ const STEP = `
29
+ <li data-bind-class="done && 'is-done'">
30
+ <span class="bk-step-icon" data-bind-hidden="!done"><span data-icon="check-circle" data-icon-size="14"></span></span>
31
+ <span class="bk-step-icon" data-bind-hidden="done"><span data-icon="circle" data-icon-size="14"></span></span>
32
+ <button type="button" class="bk-link" data-on-click="$parent.step($data)" data-bind-text="text"></button>
33
+ </li>`;
34
+
35
+ // A column heading: a person or room (day view) or a day (week view, a link to it).
36
+ const HEAD = `
37
+ <div class="bk-col-head" data-bind-class="cls" data-bind-data-colour="colour" data-bind-data-date="date" data-bind-title="tip">
38
+ <span class="bk-kind" data-bind-hidden="kind !== 'person'"><span data-icon="user" data-icon-size="13"></span></span>
39
+ <span class="bk-kind" data-bind-hidden="kind !== 'resource'"><span data-icon="box" data-icon-size="13"></span></span>
40
+ <span data-bind-text="title"></span>
41
+ </div>`;
42
+
43
+ const HOUR = `<span data-bind-style-top="top" data-bind-text="text"></span>`;
44
+
45
+ // A column: its open bands, the now line and its blocks. Every measure is a CSS length worked out in draw().
46
+ const COL = `
47
+ <div class="bk-col" data-bind-data-date="date" data-bind-data-bookable="bookableId" title="Click an empty time to book it">
48
+ {{#each windows key=id}}<div class="bk-open" data-bind-style-top="top" data-bind-style-background-position-y="bgY" data-bind-style-height="height"></div>{{/each}}
49
+ <div class="bk-now" data-bind-hidden="!nowTop" data-bind-style-top="nowTop"></div>
50
+ {{#each blocks key=id}}<div class="bk-block" data-bind-data-id="id" data-bind-class="cls" data-bind-data-colour="colour" data-bind-title="tip" tabindex="0" role="button"
51
+ data-bind-style-top="top" data-bind-style-height="height" data-bind-style-left="left" data-bind-style-width="width">
52
+ <button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
53
+ <strong data-bind-text="head"></strong><small data-bind-text="sub"></small>
54
+ </div>{{/each}}
55
+ </div>`;
21
56
 
22
57
  export async function mount(root, ctx) {
23
58
  const {kit, scope, here, boot} = ctx;
@@ -33,6 +68,16 @@ export async function mount(root, ctx) {
33
68
  const loading = M.observable(true);
34
69
  const error = M.observable('');
35
70
  const label = M.observable('');
71
+ // What the grid shows, as rows for the keyed lists.
72
+ const hasBookables = M.observable(boot.bookables.length > 0);
73
+ const hasServices = M.observable(boot.services.length > 0);
74
+ const heads = M.observable([]);
75
+ const hours = M.observable([]);
76
+ const cols = M.observable([]);
77
+ const gridCols = M.observable('1');
78
+ const gridH = M.observable('0px');
79
+ const steps = M.observable([]);
80
+ const checklistOn = M.observable(false);
36
81
  scope(M.effect(() => store.set(VIEW_KEY, view.value)));
37
82
  scope(M.effect(() => store.set(CANCELLED_KEY, showCancelled.value)));
38
83
 
@@ -40,7 +85,11 @@ export async function mount(root, ctx) {
40
85
  const card = document.createElement('div');
41
86
  card.className = 'bk-card bk-diary';
42
87
  card.innerHTML = `
43
- <div class="bk-checklist" data-part="checklist" hidden></div>
88
+ <div class="bk-checklist" data-bind-hidden="!checklistOn.value">
89
+ <header><h3><span data-icon="clipboard-list" data-icon-size="15"></span> Getting started</h3>
90
+ <button type="button" class="btn btn-xs btn-ghost" data-on-click="hideChecklist" title="Hide this list">Hide</button></header>
91
+ <ol data-each="steps.value key=id">${STEP}</ol>
92
+ </div>
44
93
  <div class="bk-bar">
45
94
  <div class="bk-seg" role="group" aria-label="View">
46
95
  <button type="button" data-view="day" data-bind-class="view.value === 'day' && 'is-on'">Day</button>
@@ -58,16 +107,37 @@ export async function mount(root, ctx) {
58
107
  <span data-help-title="The Diary" data-help="Bookings in your business's time zone (${esc(ctx.tz)}). Hatched times are outside opening hours. Click a booking to open it; right-click it (or ⋮, or Shift+F10) for Move, Done, No-show and Cancel. Click an empty time to book it. Keys: ← → earlier and later, T today, D day, W week."></span>
59
108
  </div>
60
109
  <p class="bk-note is-bad" data-bind-hidden="!error.value" data-bind-text="error.value"></p>
61
- <div class="bk-diary-wrap" data-part="grid" tabindex="-1"></div>`;
110
+ <div class="bk-diary-wrap" data-part="grid" tabindex="-1">
111
+ <div class="bk-empty" data-bind-hidden="hasBookables.value"><span data-icon="users" data-icon-size="28"></span>
112
+ <p><strong>Add who or what can be booked.</strong><br>A person (for appointments) or a room or thing - each with their opening hours.</p>
113
+ <button type="button" class="btn btn-sm btn-primary" data-on-click="addPerson"><span data-icon="plus" data-icon-size="13"></span> Add a person or room</button></div>
114
+ <p class="bk-note" data-bind-hidden="!hasBookables.value || hasServices.value">Next: <button type="button" class="bk-link" data-on-click="addService">add a service</button> - what can be booked, for how long, and with whom.</p>
115
+ <div class="bk-diary-grid" data-bind-hidden="!hasBookables.value" data-bind-class="'is-' + view.value"
116
+ data-bind-style---bk-cols="gridCols.value" data-bind-style---bk-h="gridH.value" style="--bk-hour: ${60 * PX_PER_MIN}px">
117
+ <div class="bk-diary-head" data-part="heads"><span></span><div class="bk-heads" data-each="heads.value key=key">${HEAD}</div></div>
118
+ <div class="bk-diary-body">
119
+ <div class="bk-gutter" data-each="hours.value key=id">${HOUR}</div>
120
+ <div class="bk-cols" data-each="cols.value key=key">${COL}</div>
121
+ </div>
122
+ </div>
123
+ </div>`;
62
124
  root.appendChild(card);
63
125
  const gridBox = card.querySelector('[data-part="grid"]');
64
126
  const dateIn = card.querySelector('[data-part="date"]');
65
127
  const cancelledBox = card.querySelector('[data-part="cancelled"]');
66
- const checklist = card.querySelector('[data-part="checklist"]');
67
128
  scope(kit.helpPopovers(card, {position: 'bottom'}));
68
- const bindings = M.applyBindings({view, label, error}, card);
129
+ const model = {
130
+ view, label, error, hasBookables, hasServices, heads, hours, cols, gridCols, gridH, steps, checklistOn,
131
+ hideChecklist() { store.set(CHECKLIST_KEY, true); checklistOn.value = false; },
132
+ step(s) { s.go(); },
133
+ addPerson: () => ctx.go('people', 'new'),
134
+ addService: () => ctx.go('services', 'new')
135
+ };
136
+ const bindings = M.applyBindings(model, card);
69
137
  scope(() => bindings.dispose());
70
138
  I.scan(card);
139
+ // Heads come and go with the view; their icons are drawn once the rows are in.
140
+ scope(M.effect(() => { heads.value; steps.value; requestAnimationFrame(() => { if (card.isConnected) I.scan(card); }); }));
71
141
 
72
142
  cancelledBox.checked = showCancelled.peek();
73
143
  cancelledBox.addEventListener('change', () => { showCancelled.value = cancelledBox.checked; });
@@ -84,6 +154,11 @@ export async function mount(root, ctx) {
84
154
  const s = e.target.closest('[data-step]');
85
155
  if (s) step(Number(s.dataset.step));
86
156
  });
157
+ // A week head is a link to its day.
158
+ card.querySelector('[data-part="heads"]').addEventListener('click', (e) => {
159
+ const h = e.target.closest('.bk-col-head[data-date]');
160
+ if (h) { date.value = h.dataset.date; view.value = 'day'; }
161
+ });
87
162
  const onKey = (e) => {
88
163
  if (e.defaultPrevented || e.ctrlKey || e.metaKey || e.altKey) return;
89
164
  if (e.target.closest('input, textarea, select, [contenteditable], .dm-slideover, [role="dialog"]')) return;
@@ -103,28 +178,16 @@ export async function mount(root, ctx) {
103
178
  // ---- The first-run checklist --------------------------------------------------
104
179
 
105
180
  function drawChecklist() {
106
- const steps = [
107
- {done: boot.bookables.length > 0, text: 'Add who or what can be booked - a person or a room', go: () => ctx.go('people', 'new')},
108
- {done: boot.services.length > 0, text: 'Add a service: what can be booked, for how long, and with whom', go: () => ctx.go('services', 'new')},
109
- {done: store.get('booking.settings-seen') === true, text: boot.mailReady ? 'Check the settings: your booking page, notice rules and emails' : 'Check the settings - and set up outgoing mail, or no confirmation emails are sent',
181
+ const list = [
182
+ {id: 'people', done: boot.bookables.length > 0, text: 'Add who or what can be booked - a person or a room', go: () => ctx.go('people', 'new')},
183
+ {id: 'services', done: boot.services.length > 0, text: 'Add a service: what can be booked, for how long, and with whom', go: () => ctx.go('services', 'new')},
184
+ {id: 'settings', done: store.get('booking.settings-seen') === true, text: boot.mailReady ? 'Check the settings: your booking page, notice rules and emails' : 'Check the settings - and set up outgoing mail, or no confirmation emails are sent',
110
185
  go: () => { store.set('booking.settings-seen', true); ctx.openSettings(); drawChecklist(); }},
111
- {done: store.get('booking.link-shared') === true, text: `Share your booking page: ${location.origin}${ctx.settings.bookingPath}`,
186
+ {id: 'share', done: store.get('booking.link-shared') === true, text: `Share your booking page: ${location.origin}${ctx.settings.bookingPath}`,
112
187
  go: () => { store.set('booking.link-shared', true); copy(`${location.origin}${ctx.settings.bookingPath}`, 'Booking page address copied'); drawChecklist(); }}
113
188
  ];
114
- const left = steps.filter(s => !s.done).length;
115
- if (!left || store.get(CHECKLIST_KEY) === true) { checklist.hidden = true; return; }
116
- checklist.hidden = false;
117
- checklist.innerHTML = `
118
- <header><h3><span data-icon="clipboard-list" data-icon-size="15"></span> Getting started</h3>
119
- <button type="button" class="btn btn-xs btn-ghost" data-hide title="Hide this list">Hide</button></header>
120
- <ol>${steps.map((s, i) => `<li class="${s.done ? 'is-done' : ''}"><span data-icon="${s.done ? 'check-circle' : 'circle'}" data-icon-size="14"></span>
121
- <button type="button" class="bk-link" data-step-i="${i}">${esc(s.text)}</button></li>`).join('')}</ol>`;
122
- I.scan(checklist);
123
- checklist.onclick = (e) => {
124
- if (e.target.closest('[data-hide]')) { store.set(CHECKLIST_KEY, true); checklist.hidden = true; return; }
125
- const i = e.target.closest('[data-step-i]')?.dataset.stepI;
126
- if (i !== undefined) steps[Number(i)].go();
127
- };
189
+ steps.value = list;
190
+ checklistOn.value = list.some(s => !s.done) && store.get(CHECKLIST_KEY) !== true;
128
191
  }
129
192
  drawChecklist();
130
193
 
@@ -168,134 +231,84 @@ export async function mount(root, ctx) {
168
231
  windows: windowsOn(b, d, ctx.settings.closures)}));
169
232
  }
170
233
 
234
+ const px = (n) => `${n}px`;
235
+
236
+ /** One block's row: where it sits and what it says. */
237
+ function blockRow(p, {from, to, week, colours}) {
238
+ const b = p.b;
239
+ let id = b.id;
240
+ if (p.session) { sessions.set(p.key, p.bookings); id = `session:${p.key}`; }
241
+ const s = wallClock(b.startAt, ctx.tz).time;
242
+ const e = wallClock(b.endAt, ctx.tz).time;
243
+ const time = week ? s : `${s}-${e}`;
244
+ const statusNote = b.status !== 'confirmed' ? ` · ${STATUS_NOTE[b.status] || b.status}` : '';
245
+ let head;
246
+ let sub;
247
+ let tip;
248
+ if (p.session) {
249
+ // A class: one block for everyone in it.
250
+ head = `${time} ${b.serviceName} · ${p.bookings.length} booked`;
251
+ sub = `${p.companion ? `with ${b.bookableName}` : p.bookings.map(x => x.name).join(', ')}${week && b.bookableName ? ` · ${b.bookableName}` : ''}`;
252
+ tip = `${s}-${e} ${b.serviceName}: ${p.bookings.length} booked${b.bookableName ? ` (with ${withNames(b)})` : ''}`;
253
+ } else if (p.companion) {
254
+ // Held along with someone else's booking (a court with the trainer).
255
+ head = `${time} ${b.name}`;
256
+ sub = `${b.serviceName} · with ${b.bookableName}${statusNote}`;
257
+ tip = `${s}-${e} ${b.serviceName}: ${b.name} - held with ${b.bookableName}'s booking`;
258
+ } else {
259
+ head = `${time} ${b.name}`;
260
+ sub = `${b.serviceName}${week && b.bookableName ? ` · ${withNames(b)}` : b.alsoNames?.length ? ` · with ${b.alsoNames.join(', ')}` : ''}${statusNote}`;
261
+ tip = `${s}-${e} ${b.serviceName}: ${b.name}${withNames(b) ? ` (with ${withNames(b)})` : ''}`;
262
+ }
263
+ return {
264
+ id, head, sub, tip,
265
+ cls: `is-${b.status}${p.companion ? ' is-companion' : ''}${p.session ? ' is-session' : ''}`,
266
+ colour: colours.get(b.bookableId) || 'grey',
267
+ top: px((Math.max(p.startMin, from) - from) * PX_PER_MIN),
268
+ height: px(Math.max(18, (Math.min(p.endMin, to) - Math.max(p.startMin, from)) * PX_PER_MIN - 2)),
269
+ left: `calc(${(p.lane / p.lanes) * 100}% + 2px)`,
270
+ width: `calc(${100 / p.lanes}% - 4px)`
271
+ };
272
+ }
273
+
171
274
  function draw() {
172
275
  const list = bookings.value.filter(b => showCancelled.value || b.status !== 'cancelled');
276
+ const week = view.value === 'week';
173
277
  const ds = datesFor(view.value, date.value);
174
- label.value = view.value === 'week' ? `${dayLabel(ds[0])} - ${dayLabel(ds[6])} ${ds[6].slice(0, 4)}` : `${dayLabel(ds[0])} ${ds[0].slice(0, 4)}`;
175
- const cols = columns();
176
- gridBox.innerHTML = '';
177
- if (!boot.bookables.length) {
178
- gridBox.innerHTML = `<div class="bk-empty"><span data-icon="users" data-icon-size="28"></span>
179
- <p><strong>Add who or what can be booked.</strong><br>A person (for appointments) or a room or thing - each with their opening hours.</p>
180
- <button type="button" class="btn btn-sm btn-primary" data-go="people"><span data-icon="plus" data-icon-size="13"></span> Add a person or room</button></div>`;
181
- gridBox.querySelector('[data-go]').onclick = () => ctx.go('people', 'new');
182
- I.scan(gridBox);
183
- return;
184
- }
185
- const colours = new Map(boot.bookables.map(b => [b.id, b.colour || 'blue']));
278
+ label.value = week ? `${dayLabel(ds[0])} - ${dayLabel(ds[6])} ${ds[6].slice(0, 4)}` : `${dayLabel(ds[0])} ${ds[0].slice(0, 4)}`;
279
+ hasBookables.value = boot.bookables.length > 0;
280
+ hasServices.value = boot.services.length > 0;
186
281
  sessions.clear();
282
+ if (!boot.bookables.length) { heads.value = []; hours.value = []; cols.value = []; return; }
283
+ const defs = columns();
284
+ const colours = new Map(boot.bookables.map(b => [b.id, b.colour || 'blue']));
187
285
  const placed = list.map(b => ({b, ...placeOf(b, ctx.tz)})).filter(p => ds.includes(p.date));
188
286
  const {from, to} = gridHours(boot.bookables.filter(b => b.active !== false), ds, placed);
189
- const height = (to - from) * PX_PER_MIN;
190
-
191
- const grid = document.createElement('div');
192
- grid.className = `bk-diary-grid is-${view.value}`;
193
- grid.style.setProperty('--bk-cols', String(cols.length));
194
- grid.style.setProperty('--bk-h', `${height}px`);
195
- grid.style.setProperty('--bk-hour', `${60 * PX_PER_MIN}px`);
196
-
197
- // Heads
198
- const head = document.createElement('div');
199
- head.className = 'bk-diary-head';
200
- head.appendChild(document.createElement('span'));
201
- for (const c of cols) {
202
- const h = document.createElement('div');
203
- h.className = `bk-col-head${c.today && view.value === 'week' ? ' is-today' : ''}`;
204
- if (c.colour) h.dataset.colour = c.colour;
205
- h.innerHTML = `${c.kind ? `<span data-icon="${c.kind === 'resource' ? 'box' : 'user'}" data-icon-size="13"></span> ` : ''}<span>${esc(c.title)}</span>`;
206
- if (view.value === 'week') { h.classList.add('bk-link-head'); h.title = 'Show this day'; h.onclick = () => { date.value = c.date; view.value = 'day'; }; }
207
- head.appendChild(h);
208
- }
209
- grid.appendChild(head);
287
+ gridCols.value = String(defs.length);
288
+ gridH.value = px((to - from) * PX_PER_MIN);
210
289
 
211
- // Time gutter + columns
212
- const bodyRow = document.createElement('div');
213
- bodyRow.className = 'bk-diary-body';
214
- const gutter = document.createElement('div');
215
- gutter.className = 'bk-gutter';
216
- for (let m = from; m < to; m += 60) {
217
- const t = document.createElement('span');
218
- t.style.top = `${(m - from) * PX_PER_MIN}px`;
219
- t.textContent = fromMinutes(m);
220
- gutter.appendChild(t);
221
- }
222
- bodyRow.appendChild(gutter);
290
+ heads.value = defs.map(c => ({
291
+ key: c.key, title: c.title, kind: c.kind || '', colour: c.colour || false,
292
+ cls: [c.today && week ? 'is-today' : '', week ? 'bk-link-head' : ''].filter(Boolean).join(' '),
293
+ date: week ? c.date : false, tip: week ? 'Show this day' : false
294
+ }));
295
+ const gutter = [];
296
+ for (let m = from; m < to; m += 60) gutter.push({id: m, top: px((m - from) * PX_PER_MIN), text: fromMinutes(m)});
297
+ hours.value = gutter;
223
298
 
224
299
  const nowWall = wallClock(Date.now(), ctx.tz);
225
- for (const c of cols) {
226
- const col = document.createElement('div');
227
- col.className = 'bk-col';
228
- col.dataset.date = c.date;
229
- if (c.bookableId) col.dataset.bookable = c.bookableId;
230
- col.title = 'Click an empty time to book it';
231
- for (const w of c.windows) {
232
- const band = document.createElement('div');
233
- band.className = 'bk-open';
300
+ cols.value = defs.map(c => {
301
+ const windows = c.windows.map((w, i) => {
302
+ // The band keeps its hour lines on the column's.
234
303
  const bandTop = (Math.max(w.from, from) - from) * PX_PER_MIN;
235
- band.style.top = `${bandTop}px`;
236
- // Keep its hour lines on the column's.
237
- band.style.backgroundPositionY = `${-bandTop}px`;
238
- band.style.height = `${(Math.min(w.to, to) - Math.max(w.from, from)) * PX_PER_MIN}px`;
239
- col.appendChild(band);
240
- }
241
- if (c.date === nowWall.date && nowWall.minutes >= from && nowWall.minutes <= to) {
242
- const line = document.createElement('div');
243
- line.className = 'bk-now';
244
- line.style.top = `${(nowWall.minutes - from) * PX_PER_MIN}px`;
245
- col.appendChild(line);
246
- }
304
+ return {id: `w${i}`, top: px(bandTop), bgY: px(-bandTop), height: px((Math.min(w.to, to) - Math.max(w.from, from)) * PX_PER_MIN)};
305
+ });
306
+ const nowTop = c.date === nowWall.date && nowWall.minutes >= from && nowWall.minutes <= to ? px((nowWall.minutes - from) * PX_PER_MIN) : '';
247
307
  const dayBookings = placed.filter(p => p.date === c.date).map(p => p.b);
248
308
  const items = columnItems(dayBookings, {bookableId: c.bookableId || ''}).map(it => ({...it, ...placeOf(it.b, ctx.tz)}));
249
- for (const p of layoutColumn(items)) {
250
- const b = p.b;
251
- const el = document.createElement('div');
252
- el.className = `bk-block is-${b.status}${p.companion ? ' is-companion' : ''}${p.session ? ' is-session' : ''}`;
253
- if (p.session) {
254
- sessions.set(p.key, p.bookings);
255
- el.dataset.id = `session:${p.key}`;
256
- } else {
257
- el.dataset.id = b.id;
258
- }
259
- el.dataset.colour = colours.get(b.bookableId) || 'grey';
260
- el.tabIndex = 0;
261
- el.setAttribute('role', 'button');
262
- const top = (Math.max(p.startMin, from) - from) * PX_PER_MIN;
263
- el.style.top = `${top}px`;
264
- el.style.height = `${Math.max(18, (Math.min(p.endMin, to) - Math.max(p.startMin, from)) * PX_PER_MIN - 2)}px`;
265
- el.style.left = `calc(${(p.lane / p.lanes) * 100}% + 2px)`;
266
- el.style.width = `calc(${100 / p.lanes}% - 4px)`;
267
- const s = wallClock(b.startAt, ctx.tz).time;
268
- const e = wallClock(b.endAt, ctx.tz).time;
269
- const time = view.value === 'week' ? esc(s) : `${esc(s)}-${esc(e)}`;
270
- const statusNote = b.status !== 'confirmed' ? ` · ${esc({completed: 'done', 'no-show': 'no-show', cancelled: 'cancelled', pending: 'pending'}[b.status] || b.status)}` : '';
271
- if (p.session) {
272
- // A class: one block for everyone in it.
273
- el.innerHTML = `<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
274
- <strong>${time} ${esc(b.serviceName)} · ${p.bookings.length} booked</strong><small>${p.companion ? `with ${esc(b.bookableName)}` : esc(p.bookings.map(x => x.name).join(', '))}${view.value === 'week' && b.bookableName ? ` · ${esc(b.bookableName)}` : ''}</small>`;
275
- el.title = `${s}-${e} ${b.serviceName}: ${p.bookings.length} booked${b.bookableName ? ` (with ${withNames(b)})` : ''}`;
276
- } else if (p.companion) {
277
- // Held along with someone else's booking (a court with the trainer).
278
- el.innerHTML = `<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
279
- <strong>${time} ${esc(b.name)}</strong><small>${esc(b.serviceName)} · with ${esc(b.bookableName)}${statusNote}</small>`;
280
- el.title = `${s}-${e} ${b.serviceName}: ${b.name} - held with ${b.bookableName}'s booking`;
281
- } else {
282
- el.innerHTML = `<button type="button" class="bk-kebab" aria-label="Actions" title="Actions">⋮</button>
283
- <strong>${time} ${esc(b.name)}</strong><small>${esc(b.serviceName)}${view.value === 'week' && b.bookableName ? ` · ${esc(withNames(b))}` : b.alsoNames?.length ? ` · with ${esc(b.alsoNames.join(', '))}` : ''}${statusNote}</small>`;
284
- el.title = `${s}-${e} ${b.serviceName}: ${b.name}${withNames(b) ? ` (with ${withNames(b)})` : ''}`;
285
- }
286
- col.appendChild(el);
287
- }
288
- bodyRow.appendChild(col);
289
- }
290
- grid.appendChild(bodyRow);
291
- gridBox.appendChild(grid);
292
- if (!boot.services.length) {
293
- const tip = document.createElement('p');
294
- tip.className = 'bk-note';
295
- tip.innerHTML = 'Next: <button type="button" class="bk-link">add a service</button> - what can be booked, for how long, and with whom.';
296
- tip.querySelector('button').onclick = () => ctx.go('services', 'new');
297
- gridBox.prepend(tip);
298
- }
309
+ const blocks = layoutColumn(items).map(p => blockRow(p, {from, to, week, colours}));
310
+ return {key: c.key, date: c.date, bookableId: c.bookableId || false, windows, nowTop, blocks};
311
+ });
299
312
  }
300
313
  scope(M.effect(draw));
301
314
 
@@ -360,15 +373,3 @@ export async function mount(root, ctx) {
360
373
 
361
374
  return () => {};
362
375
  }
363
-
364
- function mergeWindows(list) {
365
- const sorted = [...list].sort((a, b) => a.from - b.from);
366
- const out = [];
367
- for (const w of sorted) {
368
- const last = out[out.length - 1];
369
- if (last && w.from <= last.to) last.to = Math.max(last.to, w.to);
370
- else out.push({...w});
371
- }
372
- return out;
373
- }
374
-