@jtakeit/kit 0.8.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.
- package/lib/devdiary.mjs +199 -0
- package/package.json +2 -1
package/lib/devdiary.mjs
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diary on the dev server — so a booking form can be walked on localhost.
|
|
3
|
+
*
|
|
4
|
+
* On the platform `/api/availability`, `/api/book` and the rest are the edge's;
|
|
5
|
+
* on `astro dev` there was nothing there, and every booking form on localhost
|
|
6
|
+
* said nothing at all: no times, no button that did anything (Schorem, 15
|
|
7
|
+
* October 2026). A developer judging a page by walking it could not walk the
|
|
8
|
+
* one thing the page is for.
|
|
9
|
+
*
|
|
10
|
+
* This answers the same requests, in the same shape, from the site's own files:
|
|
11
|
+
* the hours, the zone and the slot length in `jtk/bookings.json`, the services
|
|
12
|
+
* and the people in `jtk/content` (the collections `jtk/catalogue.json` names
|
|
13
|
+
* for the bookings module). A booking is kept in memory until the server
|
|
14
|
+
* stops — so the time it took is taken — and nothing ever leaves the machine.
|
|
15
|
+
* It is not the platform's diary: no buffers between visits, no resource's own
|
|
16
|
+
* hours, no rates by the hour of the week. Close enough to walk; `preflight`
|
|
17
|
+
* with `try_booking` is still the test of the real one.
|
|
18
|
+
*
|
|
19
|
+
* import { devDiary } from '@jtakeit/kit/devdiary';
|
|
20
|
+
* server.middlewares.use('/api', devDiary(process.cwd()));
|
|
21
|
+
*/
|
|
22
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
|
|
25
|
+
const DAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
|
|
26
|
+
|
|
27
|
+
async function json(path) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(await readFile(path, 'utf8'));
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Every content file under jtk/content, with its slug. */
|
|
36
|
+
async function entries(root) {
|
|
37
|
+
const out = [];
|
|
38
|
+
const walk = async (dir) => {
|
|
39
|
+
let names = [];
|
|
40
|
+
try {
|
|
41
|
+
names = await readdir(dir, { withFileTypes: true });
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const one of names) {
|
|
46
|
+
const full = join(dir, one.name);
|
|
47
|
+
if (one.isDirectory()) await walk(full);
|
|
48
|
+
else if (one.name.endsWith('.json')) {
|
|
49
|
+
const doc = await json(full);
|
|
50
|
+
if (doc?.collection) out.push({ ...doc, slug: String(doc.path ?? one.name).split('/').filter(Boolean).pop()?.replace(/\.json$/, '') });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
await walk(join(root, 'jtk', 'content'));
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Minutes a zone is ahead of UTC on a given day, from the platform's own Intl. */
|
|
59
|
+
function offsetOf(zone, at) {
|
|
60
|
+
try {
|
|
61
|
+
const parts = new Intl.DateTimeFormat('en-US', { timeZone: zone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }).formatToParts(at);
|
|
62
|
+
const get = (type) => Number(parts.find((p) => p.type === type)?.value);
|
|
63
|
+
const local = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'));
|
|
64
|
+
return Math.round((local - at.getTime()) / 60000);
|
|
65
|
+
} catch {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** What the site's files say, read fresh on every request so an edit shows at once. */
|
|
71
|
+
async function read(root) {
|
|
72
|
+
const catalogue = (await json(join(root, 'jtk', 'catalogue.json'))) ?? {};
|
|
73
|
+
const roles = catalogue.modules?.bookings ?? {};
|
|
74
|
+
const settings = (await json(join(root, 'jtk', 'bookings.json')))?.blocks?.[0] ?? {};
|
|
75
|
+
const all = await entries(root);
|
|
76
|
+
const servicesOf = all.filter((e) => e.collection === (roles.services ?? 'services'));
|
|
77
|
+
const peopleOf = all.filter((e) => e.collection === (roles.resources ?? 'masters'));
|
|
78
|
+
const services = servicesOf.map((e) => {
|
|
79
|
+
const b = e.blocks?.[0] ?? {};
|
|
80
|
+
return { slug: e.slug, takes: Number(b.takes) || 0, costs: Number(b.costs) || 0, title: b.title ?? e.slug };
|
|
81
|
+
});
|
|
82
|
+
const people = peopleOf.map((e) => ({ slug: e.slug, does: e.blocks?.[0]?.does ?? null }));
|
|
83
|
+
return { settings, services, people };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const send = (res, status, body) => {
|
|
87
|
+
res.statusCode = status;
|
|
88
|
+
res.setHeader('Content-Type', 'application/json');
|
|
89
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
90
|
+
res.setHeader('X-Jtk-Dev-Diary', '1');
|
|
91
|
+
res.end(JSON.stringify(body));
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const bodyOf = (req) =>
|
|
95
|
+
new Promise((done) => {
|
|
96
|
+
let raw = '';
|
|
97
|
+
req.on('data', (chunk) => (raw += chunk));
|
|
98
|
+
req.on('end', () => {
|
|
99
|
+
try {
|
|
100
|
+
done(JSON.parse(raw || '{}'));
|
|
101
|
+
} catch {
|
|
102
|
+
done({});
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
/** @param {string} root the site's directory */
|
|
108
|
+
export function devDiary(root) {
|
|
109
|
+
/** start ISO → slugs of the people taken then */
|
|
110
|
+
const taken = new Map();
|
|
111
|
+
let made = 0;
|
|
112
|
+
|
|
113
|
+
return async (req, res, next) => {
|
|
114
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
115
|
+
const path = url.pathname.replace(/^\/api/, '') || '/';
|
|
116
|
+
|
|
117
|
+
if (path === '/turnstile') return send(res, 200, { sitekey: '' });
|
|
118
|
+
|
|
119
|
+
if (path === '/lead' && req.method === 'POST') {
|
|
120
|
+
const body = await bodyOf(req);
|
|
121
|
+
console.log(`[dev diary] an enquiry, kept on this machine: ${JSON.stringify(body).slice(0, 200)}`);
|
|
122
|
+
return send(res, 200, { ok: true, dev: true });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (path === '/availability') {
|
|
126
|
+
const { settings, services, people } = await read(root);
|
|
127
|
+
if (!settings.zone || !Array.isArray(settings.hours) || settings.hours.length === 0) {
|
|
128
|
+
return send(res, 412, {
|
|
129
|
+
code: 'failed_precondition',
|
|
130
|
+
message: !settings.zone ? 'nobody has said which timezone this business keeps' : 'nobody has said when this business is open',
|
|
131
|
+
details: { code: 'JTK_E_DIARY_NOT_READY', means: 'jtk/bookings.json needs `zone` and `hours`.' },
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
const asked = (url.searchParams.get('service') ?? '').split(',').concat(url.searchParams.getAll('services')).filter(Boolean);
|
|
135
|
+
const chosen = services.filter((s) => asked.includes(s.slug));
|
|
136
|
+
const takes = chosen.reduce((sum, s) => sum + s.takes, 0) || Number(settings.slot_every) || 30;
|
|
137
|
+
const costs = chosen.reduce((sum, s) => sum + s.costs, 0);
|
|
138
|
+
const every = Number(settings.slot_every) || 15;
|
|
139
|
+
const lead = Number(settings.lead) || 0;
|
|
140
|
+
const zone = settings.zone;
|
|
141
|
+
const from = new Date(url.searchParams.get('from') ?? Date.now());
|
|
142
|
+
const to = new Date(url.searchParams.get('to') ?? Date.now() + 86400000);
|
|
143
|
+
const resource = url.searchParams.get('resource') ?? '';
|
|
144
|
+
const able = people.filter((p) => (!resource || p.slug === resource) && (!p.does || chosen.every((s) => p.does.includes(s.slug))));
|
|
145
|
+
const earliest = Date.now() + lead * 60000;
|
|
146
|
+
const slots = [];
|
|
147
|
+
// Walk the days in the business's own zone, from its local midnight.
|
|
148
|
+
for (let day = new Date(from.getTime()); day < to; day = new Date(day.getTime() + 86400000)) {
|
|
149
|
+
const offset = offsetOf(zone, day);
|
|
150
|
+
const local = new Date(day.getTime() + offset * 60000);
|
|
151
|
+
const midnightUtc = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate()) - offset * 60000;
|
|
152
|
+
const weekday = DAYS[new Date(Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate())).getUTCDay()];
|
|
153
|
+
for (const span of settings.hours.filter((h) => h.day === weekday)) {
|
|
154
|
+
for (let m = Number(span.opens); m + takes <= Number(span.closes); m += every) {
|
|
155
|
+
const start = midnightUtc + m * 60000;
|
|
156
|
+
if (start < earliest || start < from.getTime() || start >= to.getTime()) continue;
|
|
157
|
+
const iso = new Date(start).toISOString();
|
|
158
|
+
const busy = taken.get(iso) ?? [];
|
|
159
|
+
const free = (able.length ? able : [{ slug: '' }]).map((p) => p.slug).filter((s) => !busy.includes(s));
|
|
160
|
+
slots.push({ start: iso, ends: new Date(start + takes * 60000).toISOString(), free: free.filter(Boolean), costs_minor: costs });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return send(res, 200, {
|
|
165
|
+
zone,
|
|
166
|
+
slots: slots.filter((s) => s.free.length > 0 || people.length === 0),
|
|
167
|
+
party_max: 0,
|
|
168
|
+
quantity_max: 1,
|
|
169
|
+
scale: settings.scale ?? 'hourly',
|
|
170
|
+
currency: settings.currency ?? '',
|
|
171
|
+
services: services.map((s) => ({ slug: s.slug, scale: settings.scale ?? 'hourly', takes: s.takes, fewest: 1, most: 1 })),
|
|
172
|
+
dev: true,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (path === '/book' && req.method === 'POST') {
|
|
177
|
+
const body = await bodyOf(req);
|
|
178
|
+
const { people, services, settings } = await read(root);
|
|
179
|
+
const start = body.start ? new Date(body.start).toISOString() : '';
|
|
180
|
+
if (!start) return send(res, 400, { code: 'invalid_argument', message: 'a booking needs a start' });
|
|
181
|
+
const busy = taken.get(start) ?? [];
|
|
182
|
+
const who = body.resource || people.map((p) => p.slug).find((s) => !busy.includes(s)) || '';
|
|
183
|
+
taken.set(start, [...busy, who]);
|
|
184
|
+
made += 1;
|
|
185
|
+
console.log(`[dev diary] booked ${body.service ?? (body.services ?? []).join('+')} at ${start}${who ? ` with ${who}` : ''} for ${body.name ?? 'somebody'} — on this machine only, nothing reached the platform`);
|
|
186
|
+
// The answer the form draws its last screen from: when, until when, with
|
|
187
|
+
// whom — without `start` and `ends` it cannot say the time, and says
|
|
188
|
+
// the booking failed instead.
|
|
189
|
+
const names = [body.service, ...(Array.isArray(body.services) ? body.services : [])].filter(Boolean);
|
|
190
|
+
const takes = services.filter((s) => names.includes(s.slug)).reduce((sum, s) => sum + s.takes, 0) || Number(settings.slot_every) || 30;
|
|
191
|
+
const ends = new Date(new Date(start).getTime() + takes * 60000).toISOString();
|
|
192
|
+
return send(res, 200, { id: `dev-${made}`, state: 'confirmed', start, ends, amount_minor: 0, currency: settings.currency ?? '', token: `dev-${made}`, manage_path: '', telegram_url: '', resource: who, dev: true });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (path === '/booking/cancel' && req.method === 'POST') return send(res, 200, { ok: true, dev: true });
|
|
196
|
+
|
|
197
|
+
return next();
|
|
198
|
+
};
|
|
199
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jtakeit/kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "The framework-free half of a site on jtakeit: the booking form as an element and as markup, for Astro through @jtakeit/astro and for any other generator directly",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
"default": "./lib/kits.mjs"
|
|
46
46
|
},
|
|
47
47
|
"./catalogue": "./lib/catalogue.mjs",
|
|
48
|
+
"./devdiary": "./lib/devdiary.mjs",
|
|
48
49
|
"./codes": "./lib/codes.mjs",
|
|
49
50
|
"./elements/booking": {
|
|
50
51
|
"types": "./types/elements-booking.d.ts",
|