OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking commits / ac9e33a

Add scheduling engine + UI/design specs
Scheduling: slot calculation algorithm, booking lifecycle (create, confirm, cancel, reschedule, no-show, auto-complete), timezone handling, recurring bookings, group sessions, conflict detection with optimistic locking, iCal sync. UI: Paperbell-inspired single-column layout, 7 page specs with ASCII wireframes (landing, package detail, calendar picker, checkout, confirmation, client portal, provider dashboard), component library (cards, buttons, forms, toasts), responsive breakpoints, zero-framework vanilla JS approach. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ac9e33a465937c093d92ec8fdbd9d66add912ec5
by plankatron <fleeky@gmail.com> · 2026-03-22T17:13:11.000Z

Changed files (2) +1439 -0

docs/design/v1-scheduling.md +752 -0
……@@ -0,0 +1,752 @@
1# hyper-booking v1 — Scheduling Engine Spec
2
3The core of the booking system. Handles provider availability,
4slot calculation, booking lifecycle, and calendar sync.
5
6---
7
8## Overview
9
10```
11Provider sets availability
12 → Engine calculates open slots
13 → Client picks a slot
14 → Engine creates booking
15 → Scheduler fires reminders
16 → Session completes or cancels
17```
18
19---
20
21## 1. Provider Availability
22
23### Weekly Schedule
24
25Recurring weekly time blocks. Each day has zero or more availability windows.
26
27```javascript
28{
29 providerId: 'prov_xyz',
30 timezone: 'America/New_York',
31 weekly: {
32 monday: [{ start: '09:00', end: '12:00' }, { start: '14:00', end: '17:00' }],
33 tuesday: [{ start: '09:00', end: '17:00' }],
34 wednesday: [{ start: '09:00', end: '12:00' }],
35 thursday: [{ start: '10:00', end: '18:00' }],
36 friday: [{ start: '09:00', end: '15:00' }],
37 saturday: [],
38 sunday: []
39 }
40}
41```
42
43- Times are in provider's local timezone
44- Empty array = day off
45- Multiple windows per day supported (lunch break, split schedule)
46
47### Date Overrides
48
49Specific dates with custom hours or marked unavailable.
50
51```javascript
52{
53 overrides: [
54 // Day off
55 { date: '2026-04-15', available: false, reason: 'Vacation' },
56
57 // Custom hours (replaces weekly for that date)
58 { date: '2026-04-20', available: true,
59 windows: [{ start: '08:00', end: '11:00' }] },
60
61 // Extra day (normally off)
62 { date: '2026-04-19', available: true,
63 windows: [{ start: '10:00', end: '14:00' }] }
64 ]
65}
66```
67
68### Booking Rules
69
70```javascript
71{
72 bufferMinutes: 15, // gap between sessions
73 advanceBooking: {
74 minHours: 24, // can't book less than 24h ahead
75 maxDays: 60 // can't book more than 60 days ahead
76 },
77 cancellation: {
78 windowHours: 24, // must cancel 24h+ before session
79 refundPolicy: 'full' // full | partial | none
80 },
81 reschedule: {
82 windowHours: 24, // must reschedule 24h+ before
83 maxPerBooking: 2 // max reschedules per booking
84 },
85 noShow: {
86 penalty: 'forfeit' // forfeit (lose session from bundle) | none
87 }
88}
89```
90
91### Storage
92
93```
94availability:{providerId} → weekly schedule + rules
95availability-overrides:{providerId} → date overrides array
96```
97
98---
99
100## 2. Slot Calculation
101
102The algorithm that turns availability + existing bookings into bookable slots.
103
104### Input
105
106```javascript
107calculateSlots({
108 providerId,
109 date, // specific date to check
110 duration, // session duration in minutes (from package)
111 timezone // client's timezone (for display)
112})
113```
114
115### Algorithm
116
117```javascript
118function calculateSlots (providerId, date, duration, clientTimezone) {
119 // 1. Get provider availability config
120 const avail = await db.get(`availability:${providerId}`)
121 const overrides = await db.get(`availability-overrides:${providerId}`)
122
123 // 2. Determine windows for this date
124 const dayOfWeek = getDayOfWeek(date, avail.timezone)
125 let windows
126
127 const override = overrides?.find(o => o.date === date)
128 if (override) {
129 if (!override.available) return [] // day off
130 windows = override.windows
131 } else {
132 windows = avail.weekly[dayOfWeek]
133 if (!windows || windows.length === 0) return [] // regular day off
134 }
135
136 // 3. Check advance booking limits
137 const now = Date.now()
138 const dateStart = toTimestamp(date, '00:00', avail.timezone)
139 const hoursUntil = (dateStart - now) / 3600000
140
141 if (hoursUntil < avail.advanceBooking.minHours) return []
142 if (hoursUntil / 24 > avail.advanceBooking.maxDays) return []
143
144 // 4. Generate all possible slots from windows
145 const allSlots = []
146 for (const window of windows) {
147 const windowStart = toTimestamp(date, window.start, avail.timezone)
148 const windowEnd = toTimestamp(date, window.end, avail.timezone)
149
150 let slotStart = windowStart
151 while (slotStart + duration * 60000 <= windowEnd) {
152 allSlots.push({
153 start: slotStart,
154 end: slotStart + duration * 60000
155 })
156 slotStart += duration * 60000 + avail.bufferMinutes * 60000
157 }
158 }
159
160 // 5. Remove slots that conflict with existing bookings
161 const existingBookings = await getBookingsForDate(providerId, date)
162 const buffer = avail.bufferMinutes * 60000
163
164 const availableSlots = allSlots.filter(slot => {
165 return !existingBookings.some(booking => {
166 const bookingStart = booking.startTime - buffer
167 const bookingEnd = booking.endTime + buffer
168 return slot.start < bookingEnd && slot.end > bookingStart
169 })
170 })
171
172 // 6. Convert to client timezone for display
173 return availableSlots.map(slot => ({
174 start: slot.start,
175 end: slot.end,
176 startDisplay: formatInTimezone(slot.start, clientTimezone),
177 endDisplay: formatInTimezone(slot.end, clientTimezone),
178 startISO: new Date(slot.start).toISOString(),
179 endISO: new Date(slot.end).toISOString()
180 }))
181}
182```
183
184### Slot Grid (Week View)
185
186For the calendar UI, calculate slots for a full week at once:
187
188```javascript
189async function getWeekSlots (providerId, weekStart, duration, clientTimezone) {
190 const days = {}
191 for (let i = 0; i < 7; i++) {
192 const date = addDays(weekStart, i)
193 const dateStr = formatDate(date)
194 days[dateStr] = await calculateSlots(providerId, dateStr, duration, clientTimezone)
195 }
196 return days
197}
198```
199
200### API
201
202```
203GET /api/providers/:slug/slots?date=2026-04-10&duration=60&tz=America/Chicago
204→ { slots: [{ start, end, startDisplay, endDisplay }] }
205
206GET /api/providers/:slug/slots?weekOf=2026-04-07&duration=60&tz=America/Chicago
207→ { days: { '2026-04-07': [...], '2026-04-08': [...], ... } }
208```
209
210---
211
212## 3. Booking Lifecycle
213
214### States
215
216```
217pending → confirmed → completed
218 → no-show
219 → cancelled
220 → rescheduled → confirmed → ...
221```
222
223### State Transitions
224
225| From | To | Trigger | Side Effects |
226|------|----|---------|-------------|
227| — | pending | Client submits booking | Hold slot |
228| pending | confirmed | Payment succeeds (or free session) | Notify both, schedule reminders, send .ics |
229| confirmed | completed | Provider marks complete (or auto after end time) | Release slot, update purchase session count |
230| confirmed | cancelled | Client or provider cancels | Release slot, process refund, cancel reminders |
231| confirmed | rescheduled | Client picks new time | Release old slot, hold new slot, update reminders |
232| confirmed | no-show | Provider marks no-show | Apply penalty per rules, update purchase session count |
233| pending | cancelled | Payment fails or timeout (15 min) | Release slot |
234
235### Create Booking
236
237```javascript
238async function createBooking (clientId, providerId, packageId, purchaseId, slotStart, slotEnd) {
239 // 1. Verify slot is still available (race condition check)
240 const conflicts = await getConflictingBookings(providerId, slotStart, slotEnd)
241 if (conflicts.length > 0) {
242 throw new ConflictError('Slot no longer available')
243 }
244
245 // 2. Verify purchase has remaining sessions
246 const purchase = await db.get(`purchase:${purchaseId}`)
247 if (purchase.sessionsUsed >= purchase.sessionsTotal) {
248 throw new ExhaustedError('No remaining sessions in package')
249 }
250
251 // 3. Create booking
252 const booking = {
253 id: generateId('bk'),
254 providerId,
255 clientId,
256 packageId,
257 purchaseId,
258 startTime: slotStart,
259 endTime: slotEnd,
260 status: 'pending',
261 rescheduleCount: 0,
262 createdAt: Date.now()
263 }
264
265 // 4. Atomic write: booking + index entries
266 const batch = db.batch()
267 batch.put(`booking:${booking.id}`, booking)
268 batch.put(`bookings-by-provider:${providerId}:${formatDate(slotStart)}:${booking.id}`, booking.id)
269 batch.put(`bookings-by-client:${clientId}:${booking.id}`, booking.id)
270 await batch.flush()
271
272 return booking
273}
274```
275
276### Confirm Booking
277
278```javascript
279async function confirmBooking (bookingId) {
280 const booking = await db.get(`booking:${bookingId}`)
281 booking.status = 'confirmed'
282 booking.confirmedAt = Date.now()
283 await db.put(`booking:${bookingId}`, booking)
284
285 // Increment sessions used on purchase
286 const purchase = await db.get(`purchase:${booking.purchaseId}`)
287 purchase.sessionsUsed++
288 await db.put(`purchase:${purchase.id}`, purchase)
289
290 // Schedule reminders
291 const notifier = getNotificationManager()
292 await notifier.scheduleReminder(booking, 'reminder-24h', 24 * 3600000)
293 await notifier.scheduleReminder(booking, 'reminder-1h', 1 * 3600000)
294
295 // Send confirmation notification
296 await notifier.notify(booking.clientId, {
297 type: 'booking-confirmed',
298 bookingId: booking.id,
299 providerName: booking.providerName,
300 dateTime: new Date(booking.startTime).toISOString(),
301 icsAttachment: generateICS(booking)
302 })
303
304 return booking
305}
306```
307
308### Cancel Booking
309
310```javascript
311async function cancelBooking (bookingId, cancelledBy, reason) {
312 const booking = await db.get(`booking:${bookingId}`)
313 const avail = await db.get(`availability:${booking.providerId}`)
314
315 // Check cancellation window
316 const hoursUntil = (booking.startTime - Date.now()) / 3600000
317 const withinWindow = hoursUntil >= avail.cancellation.windowHours
318
319 booking.status = 'cancelled'
320 booking.cancelledAt = Date.now()
321 booking.cancelledBy = cancelledBy // 'client' | 'provider'
322 booking.cancelReason = reason
323
324 await db.put(`booking:${bookingId}`, booking)
325
326 // Cancel scheduled reminders
327 const notifier = getNotificationManager()
328 await notifier.scheduler.cancel(bookingId)
329
330 // Process refund based on policy
331 if (cancelledBy === 'client' && withinWindow) {
332 if (avail.cancellation.refundPolicy === 'full') {
333 await hyperPay.refund({ paymentId: booking.paymentId })
334 }
335 }
336 // Provider cancellation always refunds
337 if (cancelledBy === 'provider') {
338 await hyperPay.refund({ paymentId: booking.paymentId })
339 }
340
341 // Decrement sessions used (return session to bundle)
342 const purchase = await db.get(`purchase:${booking.purchaseId}`)
343 purchase.sessionsUsed--
344 await db.put(`purchase:${purchase.id}`, purchase)
345
346 // Notify
347 await notifier.notify(booking.clientId, {
348 type: 'cancelled',
349 bookingId: booking.id,
350 cancelledBy,
351 refunded: withinWindow || cancelledBy === 'provider'
352 })
353
354 return booking
355}
356```
357
358### Reschedule Booking
359
360```javascript
361async function rescheduleBooking (bookingId, newStart, newEnd) {
362 const booking = await db.get(`booking:${bookingId}`)
363 const avail = await db.get(`availability:${booking.providerId}`)
364
365 // Check reschedule window
366 const hoursUntil = (booking.startTime - Date.now()) / 3600000
367 if (hoursUntil < avail.reschedule.windowHours) {
368 throw new PolicyError('Too late to reschedule')
369 }
370
371 // Check max reschedules
372 if (booking.rescheduleCount >= avail.reschedule.maxPerBooking) {
373 throw new PolicyError('Maximum reschedules reached')
374 }
375
376 // Verify new slot is available
377 const conflicts = await getConflictingBookings(booking.providerId, newStart, newEnd)
378 if (conflicts.length > 0) {
379 throw new ConflictError('New slot not available')
380 }
381
382 // Update booking
383 const oldStart = booking.startTime
384 booking.startTime = newStart
385 booking.endTime = newEnd
386 booking.rescheduleCount++
387 booking.rescheduledAt = Date.now()
388 booking.previousTimes = booking.previousTimes || []
389 booking.previousTimes.push({ start: oldStart, rescheduledAt: booking.rescheduledAt })
390
391 // Update indexes
392 const batch = db.batch()
393 batch.put(`booking:${bookingId}`, booking)
394 // Remove old date index, add new
395 batch.del(`bookings-by-provider:${booking.providerId}:${formatDate(oldStart)}:${bookingId}`)
396 batch.put(`bookings-by-provider:${booking.providerId}:${formatDate(newStart)}:${bookingId}`, bookingId)
397 await batch.flush()
398
399 // Reschedule reminders
400 const notifier = getNotificationManager()
401 await notifier.scheduler.cancel(bookingId)
402 await notifier.scheduleReminder(booking, 'reminder-24h', 24 * 3600000)
403 await notifier.scheduleReminder(booking, 'reminder-1h', 1 * 3600000)
404
405 // Notify with updated .ics
406 await notifier.notify(booking.clientId, {
407 type: 'rescheduled',
408 bookingId: booking.id,
409 oldDateTime: new Date(oldStart).toISOString(),
410 newDateTime: new Date(newStart).toISOString(),
411 icsAttachment: generateICS(booking)
412 })
413
414 return booking
415}
416```
417
418---
419
420## 4. Timezone Handling
421
422### Principles
423
4241. **Store everything in UTC** (millisecond timestamps)
4252. **Provider sets home timezone** (used for availability definition)
4263. **Client timezone auto-detected** (browser `Intl.DateTimeFormat().resolvedOptions().timeZone`)
4274. **Convert at display time only** — never store local times
428
429### Conversion
430
431```javascript
432// Provider defines availability in their local time
433// Convert to UTC for storage and comparison
434function toUTC (date, time, timezone) {
435 // e.g., '2026-04-10', '09:00', 'America/New_York'
436 // → 1744286400000 (UTC timestamp)
437 const dt = new Date(`${date}T${time}:00`)
438 // Use Intl to get UTC offset for that timezone on that date
439 const formatter = new Intl.DateTimeFormat('en-US', {
440 timeZone: timezone,
441 timeZoneName: 'shortOffset'
442 })
443 // ... offset calculation
444 return dt.getTime() - offsetMs
445}
446
447// Display slot in client's timezone
448function formatInTimezone (utcTimestamp, timezone) {
449 return new Intl.DateTimeFormat('en-US', {
450 timeZone: timezone,
451 weekday: 'short',
452 month: 'short',
453 day: 'numeric',
454 hour: 'numeric',
455 minute: '2-digit',
456 hour12: true
457 }).format(new Date(utcTimestamp))
458}
459```
460
461### DST Handling
462
463- Availability windows defined as clock time ("9am") not UTC offset
464- Recalculate UTC equivalent each day (handles DST transitions)
465- A provider who works "9am-5pm" always works 9am-5pm local, regardless of DST
466
467---
468
469## 5. Calendar Sync
470
471### iCal Export (v1)
472
473Provider's schedule as an iCal feed. Clients subscribe from Google Calendar, Apple Calendar, etc.
474
475```
476GET /api/providers/:id/calendar.ics
477→ VCALENDAR with all confirmed bookings as VEVENT entries
478```
479
480```javascript
481function generateProviderCalendar (providerId) {
482 const bookings = await getUpcomingBookings(providerId)
483
484 const events = bookings.map(b => [
485 'BEGIN:VEVENT',
486 `UID:${b.id}@hyper-booking`,
487 `DTSTART:${formatICSDate(b.startTime)}`,
488 `DTEND:${formatICSDate(b.endTime)}`,
489 `SUMMARY:${b.clientName} — ${b.packageName}`,
490 `DESCRIPTION:Package: ${b.packageName}\\nClient: ${b.clientName}`,
491 `STATUS:${b.status === 'confirmed' ? 'CONFIRMED' : 'CANCELLED'}`,
492 'END:VEVENT'
493 ].join('\r\n'))
494
495 return [
496 'BEGIN:VCALENDAR',
497 'VERSION:2.0',
498 'PRODID:-//hyper-booking//EN',
499 'METHOD:PUBLISH',
500 `X-WR-CALNAME:${providerName} Bookings`,
501 ...events,
502 'END:VCALENDAR'
503 ].join('\r\n')
504}
505```
506
507### iCal Import (v1)
508
509Provider imports external calendar to block busy times.
510
511```
512POST /api/providers/:id/calendar/import
513{ url: 'https://calendar.google.com/calendar/ical/.../basic.ics' }
514```
515
516- Server fetches iCal URL periodically (every 15 min)
517- Extracts busy times as "external blocks"
518- Slot calculation excludes external blocks
519- Stored as date overrides with source: 'external'
520
521### Google Calendar API (v2)
522
523Two-way sync — bookings appear in Google Calendar, Google Calendar events block slots.
524Requires OAuth2. Deferred to v2.
525
526---
527
528## 6. Recurring Bookings
529
530For subscriptions and bundles, auto-book the same slot weekly.
531
532```javascript
533async function createRecurringBooking (clientId, booking, recurrence) {
534 // recurrence: { type: 'weekly', count: 6 } or { type: 'weekly', until: '2026-06-01' }
535
536 const bookings = []
537 let currentStart = booking.startTime
538 let currentEnd = booking.endTime
539 const weekMs = 7 * 24 * 3600000
540
541 const count = recurrence.count || 52 // max 1 year
542 for (let i = 0; i < count; i++) {
543 // Check if slot is available
544 const conflicts = await getConflictingBookings(booking.providerId, currentStart, currentEnd)
545 if (conflicts.length === 0) {
546 const bk = await createBooking(
547 clientId, booking.providerId, booking.packageId,
548 booking.purchaseId, currentStart, currentEnd
549 )
550 bookings.push(bk)
551 }
552 // else skip that week (provider busy)
553
554 currentStart += weekMs
555 currentEnd += weekMs
556
557 if (recurrence.until && currentStart > new Date(recurrence.until).getTime()) break
558 }
559
560 return bookings
561}
562```
563
564---
565
566## 7. Group Sessions
567
568Multiple clients booking the same time slot.
569
570### Group Slot
571
572```javascript
573{
574 id: 'grp_abc',
575 providerId: 'prov_xyz',
576 packageId: 'pkg_abc',
577 startTime: 1744286400000,
578 endTime: 1744290000000,
579 capacity: 12,
580 enrolled: ['cli_1', 'cli_2', 'cli_3'],
581 waitlist: [],
582 status: 'open' // open | full | completed | cancelled
583}
584```
585
586### Booking a Group Session
587
588```javascript
589async function bookGroupSession (groupId, clientId, purchaseId) {
590 const group = await db.get(`group:${groupId}`)
591
592 if (group.enrolled.length >= group.capacity) {
593 // Add to waitlist
594 group.waitlist.push(clientId)
595 await db.put(`group:${groupId}`, group)
596 return { status: 'waitlisted', position: group.waitlist.length }
597 }
598
599 // Enroll
600 group.enrolled.push(clientId)
601 if (group.enrolled.length >= group.capacity) {
602 group.status = 'full'
603 }
604 await db.put(`group:${groupId}`, group)
605
606 // Create individual booking record (for client portal)
607 const booking = await createBooking(
608 clientId, group.providerId, group.packageId,
609 purchaseId, group.startTime, group.endTime
610 )
611 booking.groupId = groupId
612 await db.put(`booking:${booking.id}`, booking)
613
614 return { status: 'enrolled', booking }
615}
616```
617
618### Waitlist Promotion
619
620When someone cancels a group session:
621
622```javascript
623async function promoteFromWaitlist (groupId) {
624 const group = await db.get(`group:${groupId}`)
625 if (group.waitlist.length === 0) return null
626
627 const nextClientId = group.waitlist.shift()
628 group.enrolled.push(nextClientId)
629 group.status = 'open'
630 await db.put(`group:${groupId}`, group)
631
632 // Notify waitlisted client
633 await notifier.notify(nextClientId, {
634 type: 'waitlist-promoted',
635 groupId,
636 message: 'A spot opened up! You have been enrolled.'
637 })
638
639 return nextClientId
640}
641```
642
643---
644
645## 8. Conflict Detection
646
647Prevent double-booking with buffer time.
648
649```javascript
650async function getConflictingBookings (providerId, start, end) {
651 const avail = await db.get(`availability:${providerId}`)
652 const buffer = avail.bufferMinutes * 60000
653
654 const date = formatDate(start)
655 const bookings = await getBookingsForDate(providerId, date)
656
657 return bookings.filter(b => {
658 if (b.status === 'cancelled') return false
659 const bStart = b.startTime - buffer
660 const bEnd = b.endTime + buffer
661 return start < bEnd && end > bStart
662 })
663}
664```
665
666### Race Condition Handling
667
668Two clients could try to book the same slot simultaneously.
669
670**Strategy: Optimistic locking with Hyperbee CAS (compare-and-swap)**
671
672```javascript
673async function atomicBookSlot (providerId, start, end, bookingData) {
674 // Use Hyperbee batch with conflict check
675 const slotKey = `slot-lock:${providerId}:${start}`
676
677 const existing = await db.get(slotKey)
678 if (existing) {
679 throw new ConflictError('Slot already booked')
680 }
681
682 // Atomic batch: write lock + booking
683 const batch = db.batch()
684 batch.put(slotKey, { bookingId: bookingData.id, lockedAt: Date.now() })
685 batch.put(`booking:${bookingData.id}`, bookingData)
686 await batch.flush()
687
688 return bookingData
689}
690```
691
692---
693
694## 9. Auto-Complete
695
696Sessions auto-complete after end time if provider doesn't manually mark.
697
698```javascript
699// Run periodically (every 15 min) or on provider dashboard load
700async function autoCompleteExpiredBookings (providerId) {
701 const now = Date.now()
702 const today = formatDate(now)
703 const yesterday = formatDate(now - 86400000)
704
705 for (const date of [yesterday, today]) {
706 const bookings = await getBookingsForDate(providerId, date)
707 for (const booking of bookings) {
708 if (booking.status === 'confirmed' && booking.endTime < now) {
709 booking.status = 'completed'
710 booking.completedAt = now
711 booking.completedBy = 'auto'
712 await db.put(`booking:${booking.id}`, booking)
713 }
714 }
715 }
716}
717```
718
719---
720
721## 10. API Endpoints
722
723```
724# Availability (provider)
725GET /api/availability Get own availability config
726PUT /api/availability Update weekly schedule + rules
727PUT /api/availability/overrides Add/update date overrides
728POST /api/availability/import-calendar Import external iCal URL
729
730# Slots (public)
731GET /api/providers/:slug/slots Available slots for date/week + duration
732
733# Bookings (client)
734POST /api/bookings Create booking (after checkout)
735GET /api/bookings/:id Get booking details
736PUT /api/bookings/:id/reschedule Reschedule (new start/end)
737PUT /api/bookings/:id/cancel Cancel booking
738
739# Bookings (provider)
740GET /api/dashboard/bookings All bookings for date range
741PUT /api/bookings/:id/complete Mark session complete
742PUT /api/bookings/:id/no-show Mark no-show
743PUT /api/bookings/:id/notes Add provider notes
744
745# Calendar
746GET /api/providers/:id/calendar.ics iCal feed (subscribe)
747
748# Groups (provider)
749POST /api/groups Create group session
750GET /api/groups/:id Group details + attendee list
751GET /api/groups/:id/waitlist Waitlist
752```
docs/design/v1-ui.md +687 -0
……@@ -0,0 +1,687 @@
1# hyper-booking v1 — UI & Design Spec
2
3Based on Paperbell (paperbell.me/alexis-mattos) with adaptations
4for multi-rail payments and P2P architecture.
5
6---
7
8## Design Principles
9
101. **Single column, scroll-down** — no complex grids, just scroll
112. **Mobile-first** — works on phone, scales up to desktop
123. **Minimal chrome** — content-forward, generous whitespace
134. **Provider-branded** — accent color, logo, photo
145. **Zero CDN** — all assets vendored locally (P2P offline requirement)
156. **Progressive disclosure** — show what matters, hide details behind clicks
16
17---
18
19## Color System
20
21### Provider-Configurable
22
23```css
24:root {
25 --accent: #c4a265; /* provider picks this (default: warm gold) */
26 --accent-hover: #b3934f;
27 --bg: #ffffff;
28 --bg-secondary: #f8f6f2; /* warm off-white for sections */
29 --text: #1a1a1a;
30 --text-secondary: #666666;
31 --border: #e5e5e5;
32 --success: #2d8a4e;
33 --error: #d44;
34}
35```
36
37### Dark Mode (v1.1)
38Invert bg/text, keep accent. Not in v1.
39
40---
41
42## Typography
43
44```css
45body {
46 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
47 font-size: 16px;
48 line-height: 1.6;
49 color: var(--text);
50}
51
52h1 { font-size: 2rem; font-weight: 600; }
53h2 { font-size: 1.5rem; font-weight: 600; } /* section headings */
54h3 { font-size: 1.2rem; font-weight: 600; } /* card titles */
55
56.body-text { font-size: 1rem; color: var(--text-secondary); }
57.small { font-size: 0.875rem; }
58```
59
60---
61
62## Page 1: Provider Landing Page
63
64Based on website.jpg — the main public-facing page.
65
66### Layout (top to bottom)
67
68```
69┌─────────────────────────────────────────────┐
70│ [Logo] [Sign In] button │ ← nav bar
71├─────────────────────────────────────────────┤
72│ │
73│ ┌──────┐ │
74│ │ │ Provider Name │
75│ │ Photo│ Tagline / specialty │ ← hero section
76│ │ │ ┌──────────────┐ │
77│ └──────┘ │ Hero Image │ │
78│ └──────────────┘ │
79│ │
80├─────────────────────────────────────────────┤
81│ │
82│ About │
83│ │
84│ Multi-paragraph bio text in clean serif │ ← about section
85│ or sans-serif. Markdown rendered. │
86│ │
87│ [Social links row] │
88│ │
89├─────────────────────────────────────────────┤
90│ │
91│ Packages │ ← centered heading
92│ │
93│ ┌─────────────────────────────────────────┐│
94│ │ Free Discovery Call (Start Here) ││
95│ │ Description text... ┌────────┐ ││ ← package card
96│ │ [View Details →] │ Image │ ││
97│ │ └────────┘ ││
98│ └─────────────────────────────────────────┘│
99│ │
100│ ┌─────────────────────────────────────────┐│
101│ │ 30-Minute Health Coaching Session ││
102│ │ $75 · One session ┌──────┐ ││ ← package card
103│ │ [View Details →] │ Image│ ││
104│ │ └──────┘ ││
105│ └─────────────────────────────────────────┘│
106│ │
107│ ┌─────────────────────────────────────────┐│
108│ │ 6-Session Coaching Package ││
109│ │ $500 · 6 sessions · Save 10% ┌──────┐ ││ ← package card
110│ │ [View Details →] │ Image│ ││
111│ │ └──────┘ ││
112│ └─────────────────────────────────────────┘│
113│ │
114├─────────────────────────────────────────────┤
115│ │
116│ Frequently Asked Questions │ ← FAQ section
117│ │
118│ How much does this cost? [+] │
119│ ───────────────────────────────────────── │
120│ What is health coaching? [+] │ ← accordion
121│ ───────────────────────────────────────── │
122│ Is this covered by insurance? [+] │
123│ │
124├─────────────────────────────────────────────┤
125│ ┌─────────────────────────────────────────┐│
126│ │ Get Updates from me ││
127│ │ [email input ] [Submit] ││ ← email capture
128│ │ Privacy disclaimer text ││
129│ └─────────────────────────────────────────┘│
130└─────────────────────────────────────────────┘
131```
132
133### Component: Package Card
134
135```html
136<div class="package-card">
137 <div class="package-info">
138 <h3 class="package-title">6-Session Coaching Package</h3>
139 <div class="package-meta">
140 <span class="price">$500</span>
141 <span class="dot">·</span>
142 <span class="detail">6 sessions</span>
143 <span class="dot">·</span>
144 <span class="badge">Save 10%</span>
145 </div>
146 <p class="package-desc">Deep-dive coaching for lasting change...</p>
147 <a href="/provider/packages/pkg_abc" class="btn btn-accent">View Details →</a>
148 </div>
149 <div class="package-image">
150 <img src="/media/package-hero.jpg" alt="" />
151 </div>
152</div>
153```
154
155```css
156.package-card {
157 display: flex;
158 justify-content: space-between;
159 align-items: center;
160 padding: 2rem;
161 border: 1px solid var(--border);
162 border-radius: 8px;
163 margin-bottom: 1.5rem;
164}
165
166.package-info { flex: 1; padding-right: 2rem; }
167.package-image { width: 200px; height: 140px; border-radius: 8px; overflow: hidden; }
168.package-image img { width: 100%; height: 100%; object-fit: cover; }
169
170.btn-accent {
171 display: inline-block;
172 padding: 0.6rem 1.2rem;
173 background: var(--accent);
174 color: white;
175 border-radius: 20px;
176 text-decoration: none;
177 font-size: 0.9rem;
178}
179
180@media (max-width: 640px) {
181 .package-card { flex-direction: column; }
182 .package-image { width: 100%; margin-top: 1rem; }
183}
184```
185
186### Component: FAQ Accordion
187
188```html
189<div class="faq-item">
190 <button class="faq-question" onclick="toggleFaq(this)">
191 <span>How much does this cost?</span>
192 <span class="faq-icon">+</span>
193 </button>
194 <div class="faq-answer" hidden>
195 <p>Pricing varies by package. Discovery calls are always free...</p>
196 </div>
197</div>
198```
199
200```css
201.faq-question {
202 width: 100%;
203 display: flex;
204 justify-content: space-between;
205 align-items: center;
206 padding: 1.2rem 0;
207 border: none;
208 border-bottom: 1px solid var(--border);
209 background: none;
210 font-size: 1rem;
211 cursor: pointer;
212}
213
214.faq-icon { font-size: 1.5rem; transition: transform 0.2s; }
215.faq-item.open .faq-icon { transform: rotate(45deg); }
216.faq-answer { padding: 1rem 0; color: var(--text-secondary); }
217```
218
219---
220
221## Page 2: Package Detail
222
223Shown when client clicks "View Details" on a package card.
224
225```
226┌─────────────────────────────────────────────┐
227│ [← Back to Provider] [Sign In] │
228├─────────────────────────────────────────────┤
229│ │
230│ 6-Session Coaching Package │
231│ $500 · 60 min sessions · 6 sessions │
232│ │
233│ ┌───────────────────────────────────────┐ │
234│ │ Hero image (full width) │ │
235│ └───────────────────────────────────────┘ │
236│ │
237│ Description │
238│ Detailed markdown-rendered description... │
239│ │
240│ What's Included │
241│ ✓ 6 one-on-one sessions (60 min each) │
242│ ✓ Personalized wellness plan │
243│ ✓ Email support between sessions │
244│ │
245│ Pricing Options │
246│ ○ Pay in full: $450 (save 10%) │
247│ ○ 3 installments: $167/mo × 3 │
248│ ○ Pay as you go: $85/session │
249│ │
250│ ┌───────────────────────────────────────┐ │
251│ │ [Book Now — $500] │ │ ← primary CTA
252│ └───────────────────────────────────────┘ │
253│ │
254└─────────────────────────────────────────────┘
255```
256
257---
258
259## Page 3: Booking Calendar
260
261Shown after clicking "Book Now" (or for discovery calls, directly).
262
263```
264┌─────────────────────────────────────────────┐
265│ [← Back] Select a Time [Timezone ▾] │
266├─────────────────────────────────────────────┤
267│ │
268│ ┌─ Week Navigation ─────────────────────┐ │
269│ │ [←] April 7 – 13, 2026 [→] │ │
270│ └───────────────────────────────────────┘ │
271│ │
272│ Mon 7 Tue 8 Wed 9 Thu 10 ... │
273│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
274│ │9:00am│ │ │ │9:00am│ │10:00a│ │
275│ │──────│ │ No │ │──────│ │──────│ │
276│ │10:00a│ │avail │ │10:00a│ │11:00a│ │
277│ │──────│ │ │ │──────│ │──────│ │
278│ │11:00a│ │ │ │ │ │ 2:00p│ │
279│ │──────│ │ │ │ │ │──────│ │
280│ │ 2:00p│ │ │ │ │ │ 3:00p│ │
281│ │──────│ │ │ │ │ │──────│ │
282│ │ 3:00p│ │ │ │ │ │ 4:00p│ │
283│ └──────┘ └──────┘ └──────┘ └──────┘ │
284│ │
285│ Selected: Thursday, April 10 at 2:00 PM │
286│ │
287│ ┌───────────────────────────────────────┐ │
288│ │ [Confirm & Continue] │ │
289│ └───────────────────────────────────────┘ │
290│ │
291└─────────────────────────────────────────────┘
292```
293
294### Mobile Calendar (single day view)
295
296```
297┌──────────────────────────┐
298│ [←] Thu, Apr 10 [→] │
299├──────────────────────────┤
300│ │
301│ ┌────────────────────┐ │
302│ │ 10:00 AM │ │
303│ └────────────────────┘ │
304│ ┌────────────────────┐ │
305│ │ 11:00 AM │ │
306│ └────────────────────┘ │
307│ ┌────────────────────┐ │
308│ │ 2:00 PM ←selected│ │
309│ └────────────────────┘ │
310│ ┌────────────────────┐ │
311│ │ 3:00 PM │ │
312│ └────────────────────┘ │
313│ ┌────────────────────┐ │
314│ │ 4:00 PM │ │
315│ └────────────────────┘ │
316│ │
317│ [Confirm & Continue] │
318│ │
319└──────────────────────────┘
320```
321
322### Slot Button
323
324```css
325.slot-btn {
326 width: 100%;
327 padding: 0.75rem;
328 margin-bottom: 0.5rem;
329 border: 1px solid var(--border);
330 border-radius: 6px;
331 background: white;
332 cursor: pointer;
333 text-align: center;
334 transition: all 0.15s;
335}
336
337.slot-btn:hover { border-color: var(--accent); }
338.slot-btn.selected {
339 background: var(--accent);
340 color: white;
341 border-color: var(--accent);
342}
343.slot-btn.unavailable {
344 opacity: 0.3;
345 cursor: not-allowed;
346}
347```
348
349---
350
351## Page 4: Checkout
352
353After selecting a time slot. hyper-pay widget handles payment.
354
355```
356┌─────────────────────────────────────────────┐
357│ [← Back] Checkout │
358├─────────────────────────────────────────────┤
359│ │
360│ Booking Summary │
361│ ┌───────────────────────────────────────┐ │
362│ │ 6-Session Coaching Package │ │
363│ │ with Alexis Mattos │ │
364│ │ │ │
365│ │ First session: │ │
366│ │ Thursday, April 10, 2026 at 2:00 PM │ │
367│ │ (America/Chicago) │ │
368│ │ │ │
369│ │ Total: $500.00 │ │
370│ └───────────────────────────────────────┘ │
371│ │
372│ Your Info │
373│ ┌───────────────────────────────────────┐ │
374│ │ Display Name [________________] │ │
375│ │ Email [________________] │ │
376│ │ Phone (opt) [________________] │ │
377│ └───────────────────────────────────────┘ │
378│ │
379│ Payment │
380│ ┌───────────────────────────────────────┐ │
381│ │ ┌─────────────────────────────────┐ │ │
382│ │ │ hyper-pay widget │ │ │
383│ │ │ │ │ │
384│ │ │ ○ Credit/Debit Card │ │ │
385│ │ │ ○ Bank Transfer (ACH) │ │ │
386│ │ │ ○ USDC Stablecoin │ │ │
387│ │ │ ○ P2P (hyper-wallet) │ │ │
388│ │ │ │ │ │
389│ │ │ [Card number ] │ │ │
390│ │ │ [MM/YY] [CVC] │ │ │
391│ │ │ │ │ │
392│ │ └─────────────────────────────────┘ │ │
393│ └───────────────────────────────────────┘ │
394│ │
395│ ┌───────────────────────────────────────┐ │
396│ │ [Complete Booking — $500] │ │
397│ └───────────────────────────────────────┘ │
398│ │
399│ By booking you agree to the │
400│ cancellation policy (24h notice). │
401│ │
402└─────────────────────────────────────────────┘
403```
404
405For **free discovery calls**: skip payment section entirely, just show info fields + confirm button.
406
407---
408
409## Page 5: Confirmation
410
411After successful payment.
412
413```
414┌─────────────────────────────────────────────┐
415│ │
416│ ✓ Booking Confirmed │
417│ │
418│ Your session with Alexis Mattos │
419│ is confirmed! │
420│ │
421│ ┌───────────────────────────────────────┐ │
422│ │ Thursday, April 10, 2026 │ │
423│ │ 2:00 PM – 3:00 PM (CT) │ │
424│ │ │ │
425│ │ Package: 6-Session Coaching │ │
426│ │ Session 1 of 6 │ │
427│ │ │ │
428│ │ [Add to Calendar ↓] │ │
429│ └───────────────────────────────────────┘ │
430│ │
431│ What's Next │
432│ • Check your email for confirmation │
433│ • Complete your intake form (link) │
434│ • Book remaining sessions from portal │
435│ │
436│ [Go to Client Portal →] │
437│ │
438└─────────────────────────────────────────────┘
439```
440
441---
442
443## Page 6: Client Portal
444
445Passwordless — auto-authenticated via keypair in localStorage.
446
447```
448┌─────────────────────────────────────────────┐
449│ [hyper-booking] Hi, Jane [Settings] │
450├─────────────────────────────────────────────┤
451│ │
452│ Upcoming Sessions │
453│ │
454│ ┌───────────────────────────────────────┐ │
455│ │ Thu Apr 10 · 2:00 PM │ │
456│ │ 60min Coaching with Alexis Mattos │ │
457│ │ Session 1 of 6 │ │
458│ │ [Reschedule] [Cancel] │ │
459│ └───────────────────────────────────────┘ │
460│ ┌───────────────────────────────────────┐ │
461│ │ Thu Apr 17 · 2:00 PM │ │
462│ │ 60min Coaching with Alexis Mattos │ │
463│ │ Session 2 of 6 │ │
464│ │ [Reschedule] [Cancel] │ │
465│ └───────────────────────────────────────┘ │
466│ │
467│ Your Packages │
468│ │
469│ ┌───────────────────────────────────────┐ │
470│ │ 6-Session Coaching Package │ │
471│ │ ████████░░░░ 2 of 6 used │ │ ← progress bar
472│ │ [Book Next Session] │ │
473│ └───────────────────────────────────────┘ │
474│ │
475│ Past Sessions │
476│ ┌───────────────────────────────────────┐ │
477│ │ Thu Apr 3 · Completed │ │
478│ │ "Great session, discussed goals..." │ │ ← provider notes
479│ └───────────────────────────────────────┘ │
480│ │
481│ ───────────────────────────────────────── │
482│ [Payment History] [Forms] [Materials] │
483│ │
484└─────────────────────────────────────────────┘
485```
486
487---
488
489## Page 7: Provider Dashboard
490
491### Calendar View
492
493```
494┌─────────────────────────────────────────────┐
495│ [hyper-booking] Dashboard [Settings ⚙] │
496├──────┬──────────────────────────────────────┤
497│ │ │
498│ Nav │ [Day] [Week] [Month] April 2026 │
499│ │ │
500│ 📅 │ ┌──────┬──────┬──────┬──────┬────┐ │
501│ Cal │ │Mon 7 │Tue 8 │Wed 9 │Thu10 │... │ │
502│ │ ├──────┼──────┼──────┼──────┼────┤ │
503│ 👥 │ │ │ │ │ │ │ │
504│ Cli │ │ 9:00 │ │ 9:00 │ │ │ │
505│ │ │ Jane │ │ Mark │10:00 │ │ │
506│ 📦 │ │──────│ │──────│ Lisa │ │ │
507│ Pkg │ │10:00 │ │ │──────│ │ │
508│ │ │ Bob │ │ │ │ │ │
509│ 📊 │ │──────│ │ │ 2:00 │ │ │
510│ Ana │ │ │ │ │ Jane │ │ │
511│ │ │ 2:00 │ │ │──────│ │ │
512│ ⚙ │ │ Sara │ │ │ 3:00 │ │ │
513│ Set │ │ │ │ │ New │ │ │
514│ │ └──────┴──────┴──────┴──────┴────┘ │
515│ │ │
516│ │ Today's Summary │
517│ │ 4 sessions · 3 clients · $340 │
518│ │ │
519└──────┴──────────────────────────────────────┘
520```
521
522### Side Nav
523
524```
525Calendar (default view)
526Clients (client list + search)
527Packages (create/edit/archive)
528Analytics (revenue, sessions, retention)
529Availability (weekly editor + overrides)
530Forms (intake, contracts, surveys)
531Notifications (settings per type + channel)
532Settings (profile, payments, policies)
533```
534
535---
536
537## Component Library
538
539### Buttons
540
541```css
542.btn { padding: 0.6rem 1.2rem; border-radius: 6px; font-size: 0.9rem; cursor: pointer; }
543.btn-primary { background: var(--accent); color: white; border: none; }
544.btn-secondary { background: white; color: var(--text); border: 1px solid var(--border); }
545.btn-danger { background: var(--error); color: white; border: none; }
546.btn-pill { border-radius: 20px; } /* Paperbell uses pill buttons */
547.btn-full { width: 100%; padding: 1rem; font-size: 1.1rem; } /* checkout CTA */
548```
549
550### Cards
551
552```css
553.card {
554 padding: 1.5rem;
555 border: 1px solid var(--border);
556 border-radius: 8px;
557 background: white;
558 margin-bottom: 1rem;
559}
560.card-highlight { border-left: 3px solid var(--accent); }
561```
562
563### Form Inputs
564
565```css
566input, select, textarea {
567 width: 100%;
568 padding: 0.75rem;
569 border: 1px solid var(--border);
570 border-radius: 6px;
571 font-size: 1rem;
572 margin-bottom: 1rem;
573}
574input:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 2px rgba(196,162,101,0.2); }
575
576label {
577 display: block;
578 font-size: 0.875rem;
579 font-weight: 500;
580 margin-bottom: 0.25rem;
581}
582```
583
584### Toast Notifications (in-app)
585
586```css
587.toast {
588 position: fixed;
589 bottom: 2rem;
590 right: 2rem;
591 padding: 1rem 1.5rem;
592 background: var(--text);
593 color: white;
594 border-radius: 8px;
595 animation: slideIn 0.3s ease, fadeOut 0.3s ease 3s forwards;
596 z-index: 1000;
597}
598```
599
600### Progress Bar (session tracking)
601
602```css
603.progress {
604 height: 8px;
605 background: var(--border);
606 border-radius: 4px;
607 overflow: hidden;
608}
609.progress-fill {
610 height: 100%;
611 background: var(--accent);
612 border-radius: 4px;
613 transition: width 0.3s;
614}
615```
616
617---
618
619## Responsive Breakpoints
620
621```css
622/* Mobile first */
623.container { max-width: 100%; padding: 0 1rem; }
624
625/* Tablet */
626@media (min-width: 768px) {
627 .container { max-width: 720px; margin: 0 auto; }
628}
629
630/* Desktop */
631@media (min-width: 1024px) {
632 .container { max-width: 960px; }
633 .dashboard { display: grid; grid-template-columns: 220px 1fr; }
634}
635```
636
637---
638
639## Tech Stack (Frontend)
640
641- **Zero framework** — vanilla HTML/CSS/JS (like pear-git and hypernote)
642- **No build step** — serve directly, no webpack/vite
643- **All deps vendored** — no CDN, everything in `/web/vendor/`
644- **Markdown rendering** — for provider bios and package descriptions
645- **identity.js** — shared auth script (from pear-git/hypernote pattern)
646
647### Vendored Libraries (if needed)
648
649```
650web/vendor/
651├── noble-ed25519.js # identity signing
652├── noble-sha512.js # hashing
653└── marked.min.js # markdown rendering (optional)
654```
655
656---
657
658## Page Map
659
660```
661Public (no auth):
662 /:slug Provider landing page
663 /:slug/packages/:id Package detail
664 /:slug/book/:id Calendar slot picker
665 /:slug/checkout Checkout (creates identity)
666 /:slug/confirmed/:id Booking confirmation
667
668Client (auth):
669 /portal Client portal (bookings, packages, forms)
670 /portal/bookings/:id Booking detail
671 /portal/reschedule/:id Reschedule calendar
672
673Provider (auth):
674 /dashboard Calendar view (default)
675 /dashboard/clients Client list
676 /dashboard/clients/:id Client detail
677 /dashboard/packages Package management
678 /dashboard/packages/new Create package
679 /dashboard/analytics Revenue & stats
680 /dashboard/availability Schedule editor
681 /dashboard/forms Form builder
682 /dashboard/settings Profile, payments, policies
683
684Admin:
685 /admin Platform overview
686 /admin/providers Provider management
687```