@jtakeit/kit 0.4.0 → 0.5.2

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.
@@ -45,6 +45,8 @@
45
45
  * says which was taken.
46
46
  */
47
47
  import { money } from '../lib/money.mjs';
48
+ import { wordsFor } from '../lib/booking/words.mjs';
49
+ import { dressBooking } from './booking-ui.js';
48
50
 
49
51
  // The words that depend on the kind of business, as the platform says them:
50
52
  // its availability answer carries the form's, in every language, the site's
@@ -93,7 +95,9 @@ export function wireBooking(form) {
93
95
  form.dataset.jtkWired = '1';
94
96
 
95
97
  /** @type {Record<string, string>} */
96
- const words = JSON.parse(form.dataset.words ?? '{}');
98
+ // The form's own words under the page's: a form rendered by an older kit
99
+ // carries none of the newer ones.
100
+ const words = { ...wordsFor(form.dataset.locale ?? 'en', form.dataset.scale ?? ''), ...JSON.parse(form.dataset.words ?? '{}') };
97
101
  // Days or hours. The page's own setting first; the platform's answer,
98
102
  // once there is one, wins — an owner may switch without a rebuild.
99
103
  let daily = form.dataset.scale === 'daily';
@@ -129,6 +133,24 @@ export function wireBooking(form) {
129
133
  /** @type {HTMLElement} */ const hoursField = q('[data-hours-field]');
130
134
  /** @type {HTMLElement} */ const partyField = q('[data-party-field]');
131
135
  /** @type {HTMLInputElement} */ const day = q('[name=day]');
136
+ /** @type {HTMLElement | null} */ const ends = q('[data-ends]');
137
+ // The day's floor is the browser's today, not the build's: `min` was
138
+ // written when the site was built, and a site built on Friday is read on
139
+ // Saturday (10 October 2026).
140
+ {
141
+ const n = new Date();
142
+ const today = `${n.getFullYear()}-${String(n.getMonth() + 1).padStart(2, '0')}-${String(n.getDate()).padStart(2, '0')}`;
143
+ if (!day.min || day.min < today) day.min = today;
144
+ }
145
+ // What each resource does, read off the options before the element
146
+ // rewrites them from the platform's answers.
147
+ /** @type {Map<string, Set<string>>} */
148
+ const does = new Map();
149
+ for (const option of resource?.options ?? []) {
150
+ if (option.dataset.does) does.set(option.value, new Set(option.dataset.does.split(',').filter(Boolean)));
151
+ }
152
+ /** @type {Set<HTMLInputElement | HTMLOptionElement>} */
153
+ const offByMe = new Set();
132
154
  /** @type {HTMLElement} */ const slots = q('[data-slots]');
133
155
  /** @type {HTMLElement} */ const hint = q('[data-times-hint]');
134
156
  /** @type {HTMLElement} */ const error = q('[data-booking-error]');
@@ -181,12 +203,24 @@ export function wireBooking(form) {
181
203
  const offered = new Map();
182
204
  /** @type {Record<string, number>} */ let prices = {};
183
205
  /** @type {Record<string, string>} */ let classOf = {};
184
- let currency = form.dataset.currency || 'CHF';
206
+ // The site's, from the page, and then the platform's own answer below: no
207
+ // sign made up while it is asked (11 October 2026).
208
+ let currency = form.dataset.currency || '';
185
209
 
186
210
  /** @type {string | null} */ let chosen = null;
187
211
  let zone = '';
188
212
  let turnstileToken = '';
189
213
 
214
+ /** The page's own scheme — `color-scheme` on the root — or the browser's. */
215
+ function turnstileTheme() {
216
+ const said = form.dataset.turnstileTheme;
217
+ if (said === 'light' || said === 'dark' || said === 'auto') return said;
218
+ const scheme = getComputedStyle(document.documentElement).colorScheme || '';
219
+ if (/\blight\b/.test(scheme) && !/\bdark\b/.test(scheme)) return 'light';
220
+ if (/\bdark\b/.test(scheme) && !/\blight\b/.test(scheme)) return 'dark';
221
+ return 'auto';
222
+ }
223
+
190
224
  // The widget, if this host has one. Asked at runtime so the site carries
191
225
  // no key: which widget covers a host is the platform's to decide.
192
226
  void fetch(api + 'turnstile', { cache: 'no-store' })
@@ -200,6 +234,12 @@ export function wireBooking(form) {
200
234
  script.onload = () => {
201
235
  window.turnstile?.render(challenge, {
202
236
  sitekey: data.sitekey,
237
+ // The widget in the page's colours and language, not the
238
+ // browser's: a light site showed a dark box saying «Успешно» to
239
+ // a visitor whose browser was dark and Russian (10 October 2026).
240
+ // `data-turnstile-theme` on the form says otherwise.
241
+ theme: turnstileTheme(),
242
+ language: pageLang(),
203
243
  callback: (token) => { turnstileToken = token; },
204
244
  'expired-callback': () => { turnstileToken = ''; },
205
245
  });
@@ -302,11 +342,13 @@ export function wireBooking(form) {
302
342
  return midnight ? dayOf(iso, long) : `${dayOf(iso, long)}, ${clock(iso)}`;
303
343
  }
304
344
 
345
+ // On a 24-hour clock in every language: the sites are Swiss, German and
346
+ // Ukrainian, and «11:15 AM» on a Zürich salon's page reads as foreign.
305
347
  /** @param {string} iso */
306
348
  function clock(iso) {
307
349
  try {
308
350
  return new Intl.DateTimeFormat(pageLang(), {
309
- hour: '2-digit', minute: '2-digit', timeZone: zone || undefined,
351
+ hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone: zone || undefined,
310
352
  }).format(new Date(iso));
311
353
  } catch {
312
354
  return iso.slice(11, 16);
@@ -430,6 +472,55 @@ export function wireBooking(form) {
430
472
  if ([...resource.options].some((o) => o.value === was)) resource.value = was;
431
473
  }
432
474
 
475
+ /**
476
+ * A resource chosen: the services they do not do go grey and come
477
+ * unticked, with their name on them («not with Noa»); «anyone» brings
478
+ * them all back. The other direction — a service chosen, the resources
479
+ * who do not do it — the platform's answer says (`performs`).
480
+ */
481
+ function greyByResource() {
482
+ const who = resource?.value ?? '';
483
+ const option = resource?.options[resource.selectedIndex];
484
+ const name = (option?.textContent ?? '').split(' · ')[0].trim();
485
+ const can = who && !who.startsWith('class:') && !who.startsWith('empty:') && does.has(who) ? does.get(who) : null;
486
+ let moved = false;
487
+ for (const box of boxes) {
488
+ const row = box.closest('.booking__service');
489
+ const off = can !== null && !can.has(box.value);
490
+ if (off && !box.disabled) {
491
+ box.disabled = true;
492
+ offByMe.add(box);
493
+ if (box.checked) { box.checked = false; moved = true; }
494
+ } else if (!off && offByMe.has(box)) {
495
+ box.disabled = false;
496
+ offByMe.delete(box);
497
+ }
498
+ row?.classList.toggle('is-off', offByMe.has(box));
499
+ let note = row?.querySelector('.booking__service-off') ?? null;
500
+ if (offByMe.has(box) && row) {
501
+ const nameEl = row.querySelector('.booking__service-name');
502
+ if (!note && nameEl) {
503
+ note = document.createElement('span');
504
+ note.className = 'booking__service-off';
505
+ nameEl.insertAdjacentElement('afterend', note);
506
+ }
507
+ if (note) note.textContent = words.notWith.replace('{who}', name.split(' ')[0] || name);
508
+ } else {
509
+ note?.remove();
510
+ }
511
+ }
512
+ for (const o of select?.options ?? []) {
513
+ const off = can !== null && !can.has(o.value);
514
+ if (off && !o.disabled) { o.disabled = true; offByMe.add(o); }
515
+ else if (!off && offByMe.has(o)) { o.disabled = false; offByMe.delete(o); }
516
+ }
517
+ if (select && select.options[select.selectedIndex]?.disabled) {
518
+ const next = [...select.options].find((o) => !o.disabled);
519
+ if (next) { select.value = next.value; moved = true; }
520
+ }
521
+ if (moved) showTotal();
522
+ }
523
+
433
524
  // The resource chosen, if it does not do what is asked: its name, for
434
525
  // saying so in place of «nothing free on this day».
435
526
  /** @param {{ slug: string; title: string; performs?: boolean }[]} resources */
@@ -467,11 +558,26 @@ export function wireBooking(form) {
467
558
  return moved;
468
559
  }
469
560
 
561
+ // The last answer's choices, so that a form with nothing picked any more
562
+ // can offer every master again: «not for this choice» is about a choice,
563
+ // and it went on reading that way after the choice was taken back
564
+ // (9 October 2026).
565
+ let lastClasses = [];
566
+ let lastResources = [];
567
+
470
568
  async function load() {
471
569
  const mine = ++asking;
570
+ // The start picked before this reload: picked again if it is still
571
+ // offered — a service added, the same time — and said gone if not.
572
+ const wanted = chosen;
472
573
  chosen = null;
473
574
  slots.textContent = '';
474
- if (!day.value || picked().length === 0) return;
575
+ if (ends) ends.hidden = true;
576
+ if (!day.value || picked().length === 0) {
577
+ hint.textContent = picked().length === 0 ? words.pickService : words.pick;
578
+ if (lastResources.length > 0) offerChoices(lastClasses, lastResources.map((one) => ({ ...one, performs: true })), []);
579
+ return;
580
+ }
475
581
  hint.textContent = '…';
476
582
 
477
583
  const from = new Date(day.value + 'T00:00:00');
@@ -495,7 +601,9 @@ export function wireBooking(form) {
495
601
  if (mine !== asking) return;
496
602
  const data = await answer.json();
497
603
  hear(data.words);
498
- offerChoices(data.classes ?? [], data.resources ?? [], data.empty_classes ?? []);
604
+ lastClasses = data.classes ?? [];
605
+ lastResources = data.resources ?? [];
606
+ offerChoices(lastClasses, lastResources, data.empty_classes ?? []);
499
607
  if (sayEmptyServices(data.empty_services ?? [])) { void load(); return; }
500
608
  zone = data.zone;
501
609
  if (typeof data.scale === 'string') daily = data.scale === 'daily';
@@ -528,6 +636,12 @@ export function wireBooking(form) {
528
636
  return;
529
637
  }
530
638
  hint.textContent = '';
639
+ // A price beside each time only where the times differ in price — a
640
+ // rate by the hour (wiki/68). Where every time costs the same, the
641
+ // price is the services' and is said beside them: «09:00 · CHF 95» on
642
+ // thirty pills is one number read thirty times (11 October 2026).
643
+ const slotPrices = new Set(data.slots.map((one) => one.costs_minor ?? 0));
644
+ const pricedByTime = slotPrices.size > 1;
531
645
  for (const slot of data.slots) {
532
646
  const button = document.createElement('button');
533
647
  button.type = 'button';
@@ -538,10 +652,13 @@ export function wireBooking(form) {
538
652
  ? slot.ends ? `${dayAndHour(slot.start, false)} — ${dayAndHour(slot.ends, false)}` : dayAndHour(slot.start, false)
539
653
  : clock(slot.start);
540
654
  // What the booking starting here costs — the platform's number, every
541
- // hour by its own row (wiki/68) — beside the time, whenever one came.
542
- if (slot.costs_minor > 0) button.textContent += ` · ${money(slot.costs_minor, currency, pageLang())}`;
655
+ // hour by its own row (wiki/68) — beside the time, where it varies.
656
+ if (pricedByTime && slot.costs_minor > 0) button.textContent += ` · ${money(slot.costs_minor, currency, pageLang())}`;
657
+ button.dataset.start = slot.start;
543
658
  button.addEventListener('click', () => {
544
659
  chosen = slot.start;
660
+ if (hint.textContent === words.retime) hint.textContent = '';
661
+ sayEnds(slot);
545
662
  offerQuantity(data.quantity_max ?? 1, (slot.free ?? []).length);
546
663
  for (const other of slots.querySelectorAll('.booking__slot')) {
547
664
  other.classList.remove('is-chosen');
@@ -554,19 +671,48 @@ export function wireBooking(form) {
554
671
  slots.appendChild(button);
555
672
  }
556
673
  if (stay() && data.slots.length === 1) /** @type {HTMLButtonElement | null} */ (slots.firstElementChild)?.click();
674
+ else if (wanted) {
675
+ const same = /** @type {HTMLButtonElement | null} */ (slots.querySelector(`[data-start="${CSS.escape(wanted)}"]`));
676
+ if (same) same.click();
677
+ else hint.textContent = words.retime;
678
+ }
557
679
  } catch (err) {
558
- hint.textContent = err instanceof Error && err.message ? err.message : words.failed;
680
+ // The platform's sentence, where it sent one; never a whole 404 page
681
+ // (the preview's slug missing from the address, 10 October 2026).
682
+ const said = err instanceof Error ? err.message : '';
683
+ hint.textContent = said && said.length <= 200 && !/<[a-z!]/i.test(said) ? said : words.failed;
559
684
  }
560
685
  }
561
686
 
687
+ /**
688
+ * When the visit ends — the platform's own `ends`, said under the times:
689
+ * «Until about 10:45 · 1 h 15 min». Nothing by the day, which says its
690
+ * days on the pill.
691
+ * @param {{ start: string; ends?: string }} slot
692
+ */
693
+ function sayEnds(slot) {
694
+ if (!ends || daily || !slot.ends) return;
695
+ const minutes = Math.round((new Date(slot.ends).getTime() - new Date(slot.start).getTime()) / 60000);
696
+ if (!(minutes > 0)) return;
697
+ ends.dataset.clock = clock(slot.ends);
698
+ ends.dataset.length = lasting(minutes, words);
699
+ ends.textContent = `${words.until.replace('{time}', ends.dataset.clock)} · ${ends.dataset.length}`;
700
+ ends.hidden = false;
701
+ }
702
+
562
703
  select?.addEventListener('change', () => { fitHours(); void load(); });
563
704
  hours.addEventListener('change', () => void load());
705
+ // The look — the steps, the week, the parts of the day, the drawn choice
706
+ // — over the form, where the form asks for it.
707
+ if (form.dataset.ui) dressBooking(form, words);
564
708
  for (const box of boxes) box.addEventListener('change', () => { showTotal(); void load(); });
565
- resource?.addEventListener('change', () => void load());
709
+ resource?.addEventListener('change', () => { greyByResource(); void load(); });
710
+ greyByResource();
566
711
  // A new first date keeps the stay's length: five nights from the 10th
567
712
  // become five nights from the 12th, not whatever the old «until» leaves.
568
713
  let lastDay = day.value;
569
714
  day.addEventListener('change', () => {
715
+ chosen = null; // a new day starts clean
570
716
  if (lastDay && back.value && day.value) back.value = plusDays(day.value, daysBetween(lastDay, back.value));
571
717
  lastDay = day.value;
572
718
  fitBack();
@@ -657,12 +803,37 @@ export function wireBooking(form) {
657
803
  }
658
804
  if (what) {
659
805
  /** @type {HTMLElement} */ const line = q('[data-done-what]');
660
- line.textContent = words.took.replace('{what}', what);
806
+ // A person by name is «With Noa»; a class or a group is what was taken.
807
+ const byName = !made.class && !(made.group && made.group.quantity > 1) && resourceChosen();
808
+ line.textContent = byName ? words.withNamed.replace('{who}', what) : words.took.replace('{what}', what);
661
809
  line.hidden = false;
662
810
  }
663
811
 
664
812
  /** @type {HTMLAnchorElement} */ const manage = q('[data-manage]');
665
813
  if (made.manage_path) { manage.href = made.manage_path; manage.textContent = words.manage; manage.hidden = false; }
814
+ // The link is the booking's only key: shown whole, with a way to copy
815
+ // it and a sentence saying so, where the form has the box for it.
816
+ /** @type {HTMLElement | null} */ const keep = q('[data-keep]');
817
+ if (keep && made.manage_path) {
818
+ const href = new URL(made.manage_path, window.location.href).href;
819
+ /** @type {HTMLInputElement} */ const link = q('[data-keep-link]');
820
+ /** @type {HTMLButtonElement} */ const copy = q('[data-keep-copy]');
821
+ /** @type {HTMLAnchorElement} */ const open = q('[data-keep-open]');
822
+ q('[data-keep-note]').textContent = words.keep;
823
+ link.value = href;
824
+ link.setAttribute('aria-label', words.manage);
825
+ link.addEventListener('focus', () => link.select());
826
+ copy.textContent = words.copy;
827
+ copy.addEventListener('click', async () => {
828
+ try { await navigator.clipboard.writeText(href); } catch { link.select(); document.execCommand('copy'); }
829
+ copy.textContent = words.copied;
830
+ setTimeout(() => { copy.textContent = words.copy; }, 1600);
831
+ });
832
+ open.href = href;
833
+ open.textContent = words.open;
834
+ keep.hidden = false;
835
+ manage.hidden = true;
836
+ }
666
837
  /** @type {HTMLAnchorElement} */ const tg = q('[data-telegram]');
667
838
  if (made.telegram_url) { tg.href = made.telegram_url; tg.textContent = words.telegram; tg.hidden = false; }
668
839
 
@@ -0,0 +1,193 @@
1
+ /**
2
+ * The page's own card: where the site is, bottom right, on the dev server
3
+ * only (jtakeit-core, wiki/73 · §7).
4
+ *
5
+ * A scaffold just made is an empty page, and a developer looking at
6
+ * localhost while the agent writes sees nothing of what is happening. This
7
+ * card says it on the page itself: the seven phases the panel's «What the
8
+ * agent is doing» shows, by the same rules (lib/phases.mjs), and — through
9
+ * the dev session — the steps of the panel's setting up the developer can
10
+ * take meanwhile. It reads `/_jtk/state.json` from the dev server, its own
11
+ * origin, every three seconds; nothing crosses an origin and nothing is
12
+ * declared. The scaffold's integration puts it on every page under `astro
13
+ * dev` and on none of a build.
14
+ *
15
+ * Collapsed, it is a pill with the phase; a press opens it; the choice is
16
+ * remembered in this browser.
17
+ */
18
+ import { PHASES, PHASE_NAMES, phasesOf, sayPhase, meanwhile } from '../lib/phases.mjs';
19
+
20
+ const STORE = 'jtk-writing:collapsed';
21
+
22
+ const CSS = `
23
+ :host{position:fixed;right:16px;bottom:16px;z-index:2147483000;font:13px/1.45 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;-webkit-font-smoothing:antialiased;
24
+ --bg:#fbfaf7;--ink:#1d1b17;--ink2:#3f3b33;--muted:#6b665c;--faint:#9b958a;--line:#e6e1d6;--line2:#d8d2c4;--ok:#2f7d5a;--attn:#9a6b1c;--shadow:0 12px 36px rgba(30,25,15,.16),0 2px 6px rgba(30,25,15,.08)}
25
+ @media (prefers-color-scheme:dark){:host{--bg:#1c1b18;--ink:#f1ede5;--ink2:#d9d3c7;--muted:#a8a295;--faint:#716c62;--line:#33302a;--line2:#443f36;--ok:#5bb38a;--attn:#d4a24a;--shadow:0 12px 36px rgba(0,0,0,.5)}}
26
+ *{box-sizing:border-box;margin:0;padding:0}
27
+ button{font:inherit;color:inherit;background:none;border:0;cursor:pointer}
28
+ a{color:var(--ink);text-decoration:underline;text-decoration-color:var(--line2);text-underline-offset:3px}
29
+ a:hover{text-decoration-color:var(--ink)}
30
+ .pill{display:flex;align-items:center;gap:10px;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:999px;padding:9px 14px 9px 12px;box-shadow:var(--shadow);transition:transform .18s ease}
31
+ .pill:hover{transform:translateY(-1px)}
32
+ .mini{display:flex;gap:3px}.mini i{display:block;width:7px;height:7px;border-radius:999px;background:var(--line2)}
33
+ .pill .dev{font-size:10px;color:var(--faint);margin-left:2px}
34
+ .devnote{margin-top:8px;color:var(--faint);font-size:10.5px}
35
+ .card{width:min(372px,calc(100vw - 32px));background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:16px;box-shadow:var(--shadow);padding:14px 16px 12px}
36
+ .card.enter{animation:in .22s cubic-bezier(.22,.61,.36,1)}
37
+ @keyframes in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
38
+ .head{display:flex;align-items:baseline;gap:10px}.title{font-weight:600;font-size:14px}.count{margin-left:auto;color:var(--muted);font-variant-numeric:tabular-nums;font-size:12px}
39
+ .hide{color:var(--faint);line-height:1;padding:2px 4px;border-radius:6px;margin-right:-4px}.hide:hover{color:var(--ink);background:var(--line)}
40
+ .strip{display:flex;gap:4px;margin-top:12px;list-style:none}.seg{flex:1;height:5px;border-radius:999px;background:var(--line2)}
41
+ .seg.done,.mini i.done{background:var(--ok)}.seg.current,.mini i.current{background:var(--attn)}.seg.current{animation:pulse 2.4s ease-in-out infinite}
42
+ .seg.unseen{background:transparent;border:1px solid var(--line2)}
43
+ @keyframes pulse{50%{opacity:.45}}@media (prefers-reduced-motion:reduce){.seg.current,.card.enter{animation:none}}
44
+ .names{display:flex;gap:4px;margin-top:6px}.names span{flex:1;min-width:0;font-size:10px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.names .current{color:var(--ink);font-weight:600}.names .done{color:var(--muted)}
45
+ .say{display:flex;gap:9px;align-items:flex-start;margin-top:12px;font-weight:600;font-size:13.5px;line-height:1.35}.say i{flex:none;width:8px;height:8px;border-radius:999px;background:var(--attn);margin-top:5px}
46
+ .todo{list-style:none;margin:6px 0 0 17px;color:var(--muted);font-size:12.5px}.todo li+li{margin-top:2px}
47
+ .rule{height:1px;background:var(--line);margin:12px 0 10px}
48
+ .label{font-size:10.5px;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--faint)}
49
+ .steps{list-style:none;margin-top:6px}.steps li{display:flex;gap:8px;align-items:baseline;font-size:12.5px;color:var(--ink2)}.steps li+li{margin-top:3px}
50
+ .steps .o{flex:none;width:12px;height:12px;border-radius:999px;border:1.5px solid var(--line2);transform:translateY(1.5px)}
51
+ .steps .done{color:var(--faint);text-decoration:line-through}.steps .done .o{border-color:var(--ok);background:var(--ok)}
52
+ .note{margin-top:6px;color:var(--muted);font-size:12.5px}code{font:11.5px ui-monospace,SFMono-Regular,Menlo,monospace;background:var(--line);padding:1px 5px;border-radius:5px}
53
+ .panel{display:inline-block;margin-top:8px;font-size:12.5px}
54
+ .foot{margin-top:10px;color:var(--faint);font-size:11px}
55
+ `;
56
+
57
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[c]);
58
+
59
+ function ago(iso) {
60
+ const ms = Date.now() - new Date(iso).getTime();
61
+ if (!Number.isFinite(ms) || ms < 0) return 'just now';
62
+ const m = Math.round(ms / 60000);
63
+ if (m < 1) return 'just now';
64
+ if (m < 60) return `${m} min ago`;
65
+ const h = Math.round(m / 60);
66
+ return h < 24 ? `${h} h ago` : `${Math.round(h / 24)} d ago`;
67
+ }
68
+
69
+ function until(iso) {
70
+ const at = new Date(iso);
71
+ return Number.isNaN(at.getTime()) ? '' : at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
72
+ }
73
+
74
+ class JtkWriting extends HTMLElement {
75
+ constructor() {
76
+ super();
77
+ this.state = null;
78
+ this.failed = false;
79
+ this.timer = 0;
80
+ // What is on the page now: a poll that changes nothing redraws nothing,
81
+ // so the card does not blink every three seconds — and the entrance
82
+ // animation plays on a press, not on every reading.
83
+ this.shown = '';
84
+ this.enter = false;
85
+ let collapsed = false;
86
+ try { collapsed = localStorage.getItem(STORE) === '1'; } catch { /* a browser without storage shows it open */ }
87
+ this.collapsed = collapsed;
88
+ }
89
+
90
+ connectedCallback() {
91
+ const root = this.attachShadow({ mode: 'open' });
92
+ const style = document.createElement('style');
93
+ style.textContent = CSS;
94
+ this.box = document.createElement('div');
95
+ root.append(style, this.box);
96
+ root.addEventListener('click', (event) => {
97
+ const target = event.target instanceof Element ? event.target.closest('[data-toggle]') : null;
98
+ if (!target) return;
99
+ this.collapsed = !this.collapsed;
100
+ this.enter = !this.collapsed;
101
+ try { localStorage.setItem(STORE, this.collapsed ? '1' : '0'); } catch { /* fine */ }
102
+ this.render();
103
+ });
104
+ this.render();
105
+ this.poll();
106
+ }
107
+
108
+ disconnectedCallback() { clearTimeout(this.timer); }
109
+
110
+ async poll() {
111
+ try {
112
+ const answer = await fetch('/_jtk/state.json', { cache: 'no-store' });
113
+ if (!answer.ok) throw new Error(`the dev server answered ${answer.status}`);
114
+ this.state = await answer.json();
115
+ this.failed = false;
116
+ } catch {
117
+ this.failed = true;
118
+ }
119
+ this.render();
120
+ this.timer = setTimeout(() => this.poll(), this.failed ? 30000 : 3000);
121
+ }
122
+
123
+ render() {
124
+ const progress = this.state?.progress ?? null;
125
+ const platform = this.state?.platform ?? null;
126
+ const connected = platform !== null && platform.connected === true;
127
+ const half = connected && platform.error === undefined ? platform : null;
128
+ const where = phasesOf(progress, half);
129
+ const n = where.index + 1;
130
+
131
+ if (this.collapsed) {
132
+ this.show(`<button class="pill" data-toggle aria-expanded="false" aria-label="Where the site is: phase ${n} of 7">
133
+ <span class="mini">${where.phases.map((p) => `<i class="${p.state}"></i>`).join('')}</span>
134
+ <span>Being written · ${n} of 7</span><span class="dev">dev only</span></button>`);
135
+ return;
136
+ }
137
+
138
+ const say = sayPhase(where, progress, half);
139
+ const todo = progress?.missing ?? [];
140
+ const steps = meanwhile(half);
141
+ const stepsHTML = half
142
+ ? (steps.open.length + steps.done.length === 0
143
+ ? `<p class="note">Nothing left in the panel.</p>`
144
+ : `<ul class="steps">${[...steps.open, ...steps.done].map((s) =>
145
+ `<li class="${s.done ? 'done' : ''}"><i class="o"></i><span>${esc(s.name)}</span></li>`).join('')}</ul>`)
146
+ + (half.panel ? `<a class="panel" href="${esc(half.panel)}" target="_blank" rel="noopener">Open the panel ↗</a>` : '')
147
+ : connected
148
+ ? `<p class="note">The platform could not be read: ${esc(platform.error)}</p>`
149
+ : `<p class="note">Not connected to the platform yet — in the working tree: <code>npx jtkit session &lt;session_id&gt; &lt;watch_key&gt;</code>, both from the session's answer.</p>`;
150
+
151
+ const foot = [
152
+ this.failed ? 'the dev server did not answer' : progress?.changedAt ? `read from the files, changed ${ago(progress.changedAt)}` : 'read from the files',
153
+ half?.until ? `platform through the session until ${until(half.until)}` : '',
154
+ ].filter(Boolean).join(' · ');
155
+
156
+ this.show(`<section class="card${this.enter ? ' enter' : ''}" aria-label="Where the site is">
157
+ <div class="head"><span class="title">What the agent is doing</span><span class="count">phase ${n} of 7</span><button class="hide" data-toggle aria-label="Collapse">–</button></div>
158
+ <ol class="strip" aria-label="The seven phases">${where.phases.map((p, i) => `<li class="seg ${p.state}" title="${i + 1}. ${PHASE_NAMES[p.key]}"></li>`).join('')}</ol>
159
+ <div class="names" aria-hidden="true">${where.phases.map((p, i) => `<span class="${p.state}">${i + 1}. ${PHASE_NAMES[p.key]}</span>`).join('')}</div>
160
+ <p class="say"><i></i><span>${esc(say)}</span></p>
161
+ ${todo.length > 0 && where.current !== 'preview'
162
+ ? `<ul class="todo">${todo.slice(0, 4).map((line) => `<li>${esc(line)}</li>`).join('')}${todo.length > 4 ? `<li>+ ${todo.length - 4} more — <code>jtkit progress</code></li>` : ''}</ul>`
163
+ : ''}
164
+ <div class="rule"></div>
165
+ <p class="label">Meanwhile, in the panel</p>
166
+ ${stepsHTML}
167
+ <p class="foot">${esc(foot)}</p>
168
+ <p class="devnote">Shown on the dev server only, while the site is being written — never in a build.</p>
169
+ </section>`);
170
+ }
171
+
172
+ /** Put this on the page, unless it is what is there already. */
173
+ show(html) {
174
+ const key = html.replace(' enter', '');
175
+ if (key === this.shown) return;
176
+ this.shown = key;
177
+ this.box.innerHTML = html;
178
+ this.enter = false;
179
+ }
180
+ }
181
+
182
+ if (!customElements.get('jtk-writing')) customElements.define('jtk-writing', JtkWriting);
183
+
184
+ function mount() {
185
+ if (document.querySelector('jtk-writing')) return;
186
+ document.body.append(document.createElement('jtk-writing'));
187
+ }
188
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { once: true });
189
+ else mount();
190
+ // Astro's view transitions swap the body: put it back.
191
+ document.addEventListener('astro:page-load', mount);
192
+
193
+ export { JtkWriting, PHASES };
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The arithmetic behind the form's look, kept pure so it can be tested
3
+ * without a document: which part of the day an hour is, which parts a set
4
+ * of times make, and a clock read on 24 hours.
5
+ */
6
+
7
+ /** «09:15 AM» as «09:15»; «21:00» or anything else as it was. */
8
+ export function to24(text) {
9
+ return String(text ?? '').replace(/\b(\d{1,2}):(\d{2})\s*([AP]M)\b/gi, (_, h, mm, half) => {
10
+ let hour = Number(h);
11
+ if (half.toUpperCase() === 'PM' && hour < 12) hour += 12;
12
+ if (half.toUpperCase() === 'AM' && hour === 12) hour = 0;
13
+ return `${String(hour).padStart(2, '0')}:${mm}`;
14
+ });
15
+ }
16
+
17
+ /** The three parts of a day, in order. */
18
+ export const PARTS = Object.freeze(['morning', 'afternoon', 'evening']);
19
+
20
+ /**
21
+ * Which part of the day an hour is in: before noon the morning, before four
22
+ * the afternoon, the evening after.
23
+ * @param {number} hour
24
+ * @returns {'morning' | 'afternoon' | 'evening'}
25
+ */
26
+ export function partOfHour(hour) {
27
+ return hour < 12 ? 'morning' : hour < 16 ? 'afternoon' : 'evening';
28
+ }
29
+
30
+ /**
31
+ * The part each of a day's times goes under — by its hour, except that a
32
+ * part of one or two times joins the part next to it: «Evening 16:00–16:00»
33
+ * is a tab nobody needs. Fewer than `least` times is small.
34
+ *
35
+ * @param {number[]} hours the hour of each time, in order
36
+ * @param {number} [least] how many times make a part of their own
37
+ * @returns {('morning' | 'afternoon' | 'evening')[]} a part per time
38
+ */
39
+ export function partsOf(hours, least = 3) {
40
+ const count = { morning: 0, afternoon: 0, evening: 0 };
41
+ for (const hour of hours) count[partOfHour(hour)] += 1;
42
+ const small = (part) => count[part] > 0 && count[part] < least;
43
+ /** @type {Partial<Record<string, string>>} */
44
+ const join = {};
45
+ if (small('evening') && count.afternoon > 0) join.evening = 'afternoon';
46
+ if (small('morning') && count.afternoon > 0) join.morning = 'afternoon';
47
+ if (small('afternoon') && !join.evening && !join.morning) join.afternoon = count.morning >= count.evening ? 'morning' : 'evening';
48
+ return hours.map((hour) => /** @type {'morning' | 'afternoon' | 'evening'} */ (join[partOfHour(hour)] ?? partOfHour(hour)));
49
+ }
50
+
51
+ /** A day's name in the catalogue's spelling, from a date: `monday`. */
52
+ export const DAY_NAMES = Object.freeze(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday']);
53
+
54
+ /** `YYYY-MM-DD` of a date, in the browser's own day — never `toISOString`, which is UTC's. */
55
+ export function ymd(d) {
56
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
57
+ }
58
+
59
+ /** The date `YYYY-MM-DD` names, at local midnight; null for anything else. */
60
+ export function parseDay(text) {
61
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text ?? '');
62
+ return m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : null;
63
+ }
@@ -49,6 +49,7 @@ export const SENDS = Object.freeze(['service', 'services', 'resource', 'class',
49
49
  * @property {{ day: string, from: number, until: number, costs: number }[]} [rates] a price by the hour of the week instead of `costs`: the list says «from …», and the price is the slot's, printed beside the time
50
50
  * @property {string} [per] `'person'` for a price times the party — a class per place, shoes; the list says so and the running total multiplies
51
51
  * @property {string} [group] a heading the services are listed under
52
+ * @property {string[]} [does] for a resource: the services they perform, by slug — the element greys the others out once they are chosen; empty or absent is everything
52
53
  */
53
54
 
54
55
  /**
@@ -73,6 +74,8 @@ export function priceFrom(one) {
73
74
  * @property {string} [api] where `/api/` is from this page — under the preview it carries a path, so pass the address as your generator writes a root-relative one (Astro's `under('/api/')`)
74
75
  * @property {string} [tag] the element's name around the form, `jtk-booking`; `''` for none
75
76
  * @property {Date} [now] the day the picker starts from; the build's, by default
77
+ * @property {string[]} [open] the days the business opens, by name (`monday`…) — the week of days strikes the others out; empty is every day
78
+ * @property {boolean | string} [ui] the form's look — two steps, the week of days, the times by the part of the day, the drawn choice of whom — on by default; `false` for the plain form, or the ones wanted by name: `'week parts'`
76
79
  */
77
80
 
78
81
  const ESCAPE = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
@@ -103,8 +106,8 @@ function priceSaid(one, currency, lang) {
103
106
  */
104
107
  export function renderBookingForm(props) {
105
108
  const {
106
- services, resources = [], locale, daysAhead = 30, combine = false, currency = 'CHF', scale = '', kind = '',
107
- api = '/api/', tag = 'jtk-booking', now = new Date(),
109
+ services, resources = [], locale, daysAhead = 30, combine = false, currency = '', scale = '', kind = '',
110
+ api = '/api/', tag = 'jtk-booking', now = new Date(), open = [], ui = true,
108
111
  } = props;
109
112
  if (!Array.isArray(services)) throw new TypeError('renderBookingForm: services is a list of { slug, title }');
110
113
  const unsaid = kind !== '' && kind !== 'salon';
@@ -151,7 +154,7 @@ export function renderBookingForm(props) {
151
154
  <label class="field__label" for="booking-resource" data-say="resource">${esc(WORDS.resource)}</label>
152
155
  <select class="field__input" id="booking-resource" name="resource" data-say-wait>
153
156
  <option value="" data-say="any">${esc(WORDS.any)}</option>
154
- ${resources.map((one) => `<option value="${esc(one.slug)}">${esc(one.title)}</option>`).join('\n')}
157
+ ${resources.map((one) => `<option value="${esc(one.slug)}"${Array.isArray(one.does) && one.does.length > 0 ? ` data-does="${esc(one.does.join(','))}"` : ''}>${esc(one.title)}</option>`).join('\n')}
155
158
  </select>
156
159
  </div>`
157
160
  : '';
@@ -162,7 +165,7 @@ export function renderBookingForm(props) {
162
165
  // title, which the owner edits on the entry. None is the owner's to edit
163
166
  // here, and `jtk catalogue` asked whose all seventeen were on a fresh
164
167
  // restaurant (30 September 2026).
165
- const form = `<form class="booking${unsaid ? ' booking--unsaid' : ''}" data-booking data-locale="${esc(locale)}" data-currency="${esc(currency)}" data-words="${esc(JSON.stringify(WORDS))}" data-scale="${esc(scale)}" data-api="${esc(api)}" data-sends="${SENDS.join(',')}" novalidate data-jtk-fixed>
168
+ const form = `<form class="booking${unsaid ? ' booking--unsaid' : ''}" data-booking data-locale="${esc(locale)}" data-currency="${esc(currency)}" data-words="${esc(JSON.stringify(WORDS))}" data-scale="${esc(scale)}" data-api="${esc(api)}" data-sends="${SENDS.join(',')}" data-open="${esc(open.join(','))}"${ui ? ` data-ui="${ui === true ? 'steps week parts select' : esc(ui)}"` : ''} novalidate data-jtk-fixed>
166
169
  <noscript><p class="booking__note">${esc(WORDS.noScript)}</p></noscript>
167
170
 
168
171
  <div class="field">
@@ -201,6 +204,7 @@ export function renderBookingForm(props) {
201
204
  <legend class="field__label">${esc(WORDS.time)}</legend>
202
205
  <p class="booking__hint" data-times-hint aria-live="polite">${esc(WORDS.pick)}</p>
203
206
  <div class="booking__slots" data-slots></div>
207
+ <p class="booking__ends" data-ends hidden></p>
204
208
  <p class="booking__total" data-stay-total hidden></p>
205
209
  </fieldset>
206
210
 
@@ -231,6 +235,14 @@ export function renderBookingForm(props) {
231
235
  <p class="booking__done-line" data-done-line></p>
232
236
  <p class="booking__done-when" data-done-when></p>
233
237
  <p class="booking__done-when" data-done-what hidden></p>
238
+ <div class="booking__keep" data-keep hidden>
239
+ <p class="booking__note" data-keep-note></p>
240
+ <div class="booking__keep-row">
241
+ <input class="field__input booking__keep-link" type="text" readonly data-keep-link>
242
+ <button type="button" class="booking__keep-copy" data-keep-copy></button>
243
+ <a class="booking__keep-open" data-keep-open></a>
244
+ </div>
245
+ </div>
234
246
  <p class="booking__note" data-pay-note hidden></p>
235
247
  <p class="booking__links">
236
248
  <a data-pay hidden></a>