@ossy/booking 1.16.2 → 3.0.1

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 (92) hide show
  1. package/README.md +48 -21
  2. package/package.json +11 -8
  3. package/src/AvailabilityEditor.jsx +2 -13
  4. package/src/BookingList.jsx +1 -1
  5. package/src/BookingProductHome.jsx +38 -0
  6. package/src/BookingSalesPage.jsx +55 -0
  7. package/src/Definition.js +2 -2
  8. package/src/HeroCover.jsx +5 -5
  9. package/src/SalesSection.jsx +7 -15
  10. package/src/ServiceCard.jsx +2 -2
  11. package/src/availability-action-maps.js +39 -0
  12. package/src/availability-engine.js +18 -14
  13. package/src/availability-engine.spec.js +36 -4
  14. package/src/availability-setup.page.jsx +45 -46
  15. package/src/{availability.resource.js → availability.schema.js} +1 -5
  16. package/src/booking-cancellation.email.jsx +1 -1
  17. package/src/booking-card.component.jsx +3 -3
  18. package/src/booking-confirmation.email.jsx +5 -3
  19. package/src/booking-detail.page.jsx +15 -10
  20. package/src/booking-email-token.js +45 -0
  21. package/src/booking-home-content.js +50 -0
  22. package/src/booking-list.aggregate.js +65 -0
  23. package/src/booking-reminder.email.jsx +4 -3
  24. package/src/booking-request.email.jsx +5 -2
  25. package/src/booking-resources.js +3 -3
  26. package/src/{booking.resource.js → booking.schema.js} +12 -3
  27. package/src/bookings.component.jsx +26 -25
  28. package/src/bookings.page.jsx +29 -16
  29. package/src/cancel-booking.action.js +1 -1
  30. package/src/cancel-booking.task.js +14 -17
  31. package/src/confirm-booking.action.js +1 -1
  32. package/src/confirm-booking.api.js +35 -0
  33. package/src/confirm-booking.task.js +26 -24
  34. package/src/create-booking.action.js +1 -1
  35. package/src/create-booking.task.js +73 -37
  36. package/src/create-service.action.js +5 -1
  37. package/src/create-service.task.js +7 -7
  38. package/src/decline-booking.action.js +1 -1
  39. package/src/decline-booking.api.js +35 -0
  40. package/src/decline-booking.task.js +15 -18
  41. package/src/delete-service.action.js +1 -1
  42. package/src/delete-service.task.js +12 -15
  43. package/src/en.translations.json +83 -37
  44. package/src/get-availability.action.js +1 -1
  45. package/src/get-availability.task.js +5 -4
  46. package/src/get-available-slots.action.js +1 -1
  47. package/src/get-available-slots.task.js +16 -13
  48. package/src/get-services.action.js +1 -1
  49. package/src/get-services.task.js +13 -6
  50. package/src/home.page.jsx +14 -46
  51. package/src/index.js +17 -0
  52. package/src/list-bookings.action.js +1 -1
  53. package/src/list-bookings.task.js +33 -16
  54. package/src/locations.js +6 -4
  55. package/src/open-availability-setup.action.js +5 -0
  56. package/src/open-bookings.action.js +5 -0
  57. package/src/open-create-service.action.js +5 -0
  58. package/src/open-services.action.js +5 -0
  59. package/src/provider-availability.flow.js +39 -0
  60. package/src/provider-fields.js +34 -0
  61. package/src/provider-onboard.flow.js +25 -0
  62. package/src/public-booking-form.form.js +7 -0
  63. package/src/public-booking-form.schema.js +12 -0
  64. package/src/public-booking.page.jsx +60 -57
  65. package/src/resolve-workspace-contact.js +55 -0
  66. package/src/save-availability.action.js +5 -1
  67. package/src/save-availability.task.js +26 -28
  68. package/src/schema-ids.js +7 -0
  69. package/src/select-duration-30.action.js +4 -0
  70. package/src/select-duration-60.action.js +4 -0
  71. package/src/select-duration-90.action.js +4 -0
  72. package/src/send-booking-reminder.task.js +39 -14
  73. package/src/service-card-slot.component.jsx +4 -4
  74. package/src/service-detail.component.jsx +4 -4
  75. package/src/service.form.js +7 -0
  76. package/src/service.schema.js +14 -0
  77. package/src/services.page.jsx +62 -58
  78. package/src/stories/fixtures.js +149 -0
  79. package/src/sv.translations.json +83 -37
  80. package/src/time.js +27 -0
  81. package/src/toggle-availability-day-0.action.js +4 -0
  82. package/src/toggle-availability-day-1.action.js +4 -0
  83. package/src/toggle-availability-day-2.action.js +4 -0
  84. package/src/toggle-availability-day-3.action.js +4 -0
  85. package/src/toggle-availability-day-4.action.js +4 -0
  86. package/src/toggle-availability-day-5.action.js +4 -0
  87. package/src/toggle-availability-day-6.action.js +4 -0
  88. package/src/update-service.action.js +1 -1
  89. package/src/update-service.task.js +13 -16
  90. package/src/workspace-services.js +5 -0
  91. package/src/moduleStatus.js +0 -2
  92. package/src/service.resource.js +0 -32
package/README.md CHANGED
@@ -1,46 +1,73 @@
1
1
  # @ossy/booking
2
2
 
3
- Booking feature package for the Ossy platform. Lets consultants define services, set weekly availability, and accept client bookings.
3
+ Booking feature package for the Ossy platform. Lets providers define services, set weekly availability, and accept client bookings.
4
4
 
5
5
  ## Pages
6
6
 
7
7
  | Page | Path | Audience |
8
8
  |------|------|----------|
9
- | `public-booking` | `/book/:consultantSlug` · `/boka/:consultantSlug` | Public (clients) |
10
- | `services` | `/services` · `/tjanster` | Consultant (authenticated) |
11
- | `bookings` | `/bookings` · `/bokningar` | Consultant (authenticated) |
12
- | `availability-setup` | `/availability` · `/tillganglighet` | Consultant (authenticated) |
13
- | `booking-detail` | `/bookings/:bookingId` · `/bokningar/:bookingId` | Consultant (authenticated) |
9
+ | `public-booking` | `/book/:providerSlug` · `/boka/:providerSlug` | Public (clients) |
10
+ | `services` | `/services` · `/tjanster` | Provider (authenticated) |
11
+ | `bookings` | `/bookings` · `/bokningar` | Provider (authenticated) |
12
+ | `availability-setup` | `/availability` · `/tillganglighet` | Provider (authenticated) |
13
+ | `booking-detail` | `/bookings/:bookingId` · `/bokningar/:bookingId` | Provider (authenticated) |
14
14
 
15
15
  ## Public booking flow
16
16
 
17
17
  1. Client opens `public-booking` (no auth; `metadata.public: true`)
18
18
  2. Picks a service → calendar slot → contact details
19
- 3. `booking/create` stores a pending booking and sends notification emails
19
+ 3. `@ossy/booking/actions/create` stores a pending booking and sends notification emails
20
20
 
21
- ## Service actions
21
+ ## Actions
22
+
23
+ Actions are declared in `*.action.js` and discovered at build time. Each action delegates to a matching `*.task.js`.
22
24
 
23
25
  | Action | Access |
24
26
  |--------|--------|
25
- | `booking/get-services` | public |
26
- | `booking/create-service` | workspace |
27
- | `booking/update-service` | workspace |
28
- | `booking/delete-service` | workspace |
27
+ | `@ossy/booking/actions/get-services` | public |
28
+ | `@ossy/booking/actions/create-service` | workspace |
29
+ | `@ossy/booking/actions/update-service` | workspace |
30
+ | `@ossy/booking/actions/delete-service` | workspace |
31
+ | `@ossy/booking/actions/create` | public |
32
+ | `@ossy/booking/actions/list` | workspace |
33
+ | `@ossy/booking/actions/confirm` | workspace |
34
+ | `@ossy/booking/actions/decline` | workspace |
35
+ | `@ossy/booking/actions/cancel` | authenticated |
36
+ | `@ossy/booking/actions/get-availability` | workspace |
37
+ | `@ossy/booking/actions/save-availability` | workspace |
38
+ | `@ossy/booking/actions/get-available-slots` | public |
39
+
40
+ ## Schemas
41
+
42
+ Resource schemas live in `src/*.schema.js` and are auto-discovered by `@ossy/app build`. Canonical ids are exported from `schema-ids.js` as `BookingSchema`:
43
+
44
+ | Schema | Id | File |
45
+ |--------|----|------|
46
+ | Service | `@ossy/booking/schema/service` | `service.schema.js` |
47
+ | Booking | `@ossy/booking/schema/booking` | `booking.schema.js` |
48
+ | Availability | `@ossy/booking/schema/availability` | `availability.schema.js` |
49
+ | Public booking form | `@ossy/booking/schema/public-booking-form` | `public-booking-form.schema.js` |
29
50
 
30
- ## Resource templates
51
+ Documents are stored under canonical locations. Import paths from `@ossy/booking` (`services`, `bookings`, `availability`).
31
52
 
32
- - `@ossy/booking/service` what a consultant sells (`/@ossy/booking/services/`)
33
- - `@ossy/booking/booking` — an appointment (`/@ossy/booking/bookings/`)
34
- - `@ossy/booking/availability` — weekly availability config (`/@ossy/booking/availability/`)
53
+ Tasks write resources via `commitResource` / `mutateResource` from `@ossy/resources/server`, using `schemaId` on resource events (ADR 0008).
35
54
 
36
- Import locations from `@ossy/booking` (`services`, `bookings`, `availability`).
55
+ ## Projections
37
56
 
38
- ## Resource view slots
57
+ | Projection | Id | Purpose |
58
+ |------------|----|---------|
59
+ | `BookingListProjection` | `@ossy/booking/data/booking-list` | Workspace-scoped booking list; `@ossy/booking/actions/list` reads this first and falls back to a resource scan |
60
+
61
+ ## View slots
39
62
 
40
63
  | Component | Slot key |
41
64
  |-----------|----------|
42
- | `service-detail.component.jsx` | `resource:booking/service/detail` |
43
- | `service-card-slot.component.jsx` | `resource:booking/service/card` |
44
- | `booking-card.component.jsx` | `resource:booking/booking/card` |
65
+ | `service-detail.component.jsx` | `@ossy/booking/view/service` |
66
+ | `service-card-slot.component.jsx` | `@ossy/booking/view/service/card` |
67
+ | `booking-card.component.jsx` | `@ossy/booking/view/booking/card` |
45
68
 
46
69
  See [SPEC.md](./SPEC.md) for the full product specification.
70
+
71
+ ## Preview
72
+
73
+ Booking pages and flows are exercised in **`@ossy/app-test`** on the production build path. Enable the package in `devEntitlements`, then open Booking from the sidebar or `/packages/booking`. See [`packages/app-test/README.md`](../app-test/README.md).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ossy/booking",
3
- "description": "Booking feature package — services, availability, and appointment management for Ossy consultants",
4
- "version": "1.16.2",
3
+ "description": "Booking feature package — services, availability, and appointment management for Ossy providers",
4
+ "version": "3.0.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "module": "./src/index.js",
@@ -14,13 +14,16 @@
14
14
  "src": "./src"
15
15
  },
16
16
  "dependencies": {
17
- "@ossy/email": "^1.6.2",
18
- "@ossy/event-store": "^1.8.2",
19
- "@ossy/observability": "^1.8.2",
20
- "@ossy/platform": "^1.39.2",
21
- "@ossy/resources": "^1.12.2"
17
+ "@ossy/email": "^3.0.1",
18
+ "@ossy/event-store": "^3.0.1",
19
+ "@ossy/observability": "^3.0.1",
20
+ "@ossy/platform": "^3.0.1",
21
+ "@ossy/resources": "^3.0.1",
22
+ "@ossy/users": "^3.0.1",
23
+ "@ossy/workspaces": "^3.0.1"
22
24
  },
23
25
  "peerDependencies": {
26
+ "@ossy/app": ">=1.0.0",
24
27
  "@ossy/design-system": ">=1.0.0",
25
28
  "@ossy/router-react": ">=1.0.0",
26
29
  "@ossy/sdk-react": ">=1.0.0",
@@ -34,5 +37,5 @@
34
37
  "/src",
35
38
  "README.md"
36
39
  ],
37
- "gitHead": "2b8745d57fee8b6c08787e2755b4df489291a5cd"
40
+ "gitHead": "4a70ec216448680d2fddc57b2a8a0077489720ae"
38
41
  }
@@ -1,7 +1,7 @@
1
1
  import React, { useState, useMemo } from 'react'
2
2
  import { View, Text, Button, Input, useLocale } from '@ossy/design-system'
3
3
 
4
- const DEFAULT_WINDOW = { enabled: false, startTime: '09:00', endTime: '17:00', bufferMinutes: 0 }
4
+ const DEFAULT_WINDOW = { enabled: false, startTime: '09:00', endTime: '17:00' }
5
5
 
6
6
  const buildInitialState = (availability = []) => {
7
7
  const state = Array.from({ length: 7 }, () => ({ ...DEFAULT_WINDOW }))
@@ -11,7 +11,6 @@ const buildInitialState = (availability = []) => {
11
11
  enabled: true,
12
12
  startTime: window.startTime ?? '09:00',
13
13
  endTime: window.endTime ?? '17:00',
14
- bufferMinutes: window.bufferMinutes ?? 0,
15
14
  }
16
15
  }
17
16
  }
@@ -39,11 +38,10 @@ export const AvailabilityEditor = ({ availability = [], onSave }) => {
39
38
  const windows = days
40
39
  .map((d, idx) => ({ dayOfWeek: idx, ...d }))
41
40
  .filter((d) => d.enabled)
42
- .map(({ dayOfWeek, startTime, endTime, bufferMinutes }) => ({
41
+ .map(({ dayOfWeek, startTime, endTime }) => ({
43
42
  dayOfWeek,
44
43
  startTime,
45
44
  endTime,
46
- bufferMinutes: Number(bufferMinutes),
47
45
  }))
48
46
 
49
47
  onSave?.(windows)
@@ -87,15 +85,6 @@ export const AvailabilityEditor = ({ availability = [], onSave }) => {
87
85
  onChange={(v) => update(idx, { endTime: v })}
88
86
  size="s"
89
87
  />
90
- <Text color="secondary" size="s">{t('booking.availabilityEditor.buffer')}</Text>
91
- <Input
92
- type="number"
93
- value={String(days[idx].bufferMinutes)}
94
- onChange={(v) => update(idx, { bufferMinutes: v })}
95
- size="s"
96
- style={{ width: '4rem' }}
97
- />
98
- <Text color="secondary" size="s">{t('booking.availabilityEditor.min')}</Text>
99
88
  </View>
100
89
  ) : (
101
90
  <Text color="secondary" size="s">{t('booking.availabilityEditor.unavailable')}</Text>
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { View, Title, Text, Badge } from '@ossy/design-system'
2
+ import { View, Text, Badge } from '@ossy/design-system'
3
3
 
4
4
  const STATUS_VARIANT = {
5
5
  pending: 'warning',
@@ -0,0 +1,38 @@
1
+ import React from 'react'
2
+ import { useRouter } from '@ossy/router-react'
3
+ import { Text, View, Button, Page, useLocale } from '@ossy/design-system'
4
+ import { metadata as OpenAvailabilitySetup } from './open-availability-setup.action.js'
5
+ import { metadata as OpenServices } from './open-services.action.js'
6
+ import { metadata as OpenBookings } from './open-bookings.action.js'
7
+
8
+ export default function BookingProductHome () {
9
+ const { t } = useLocale()
10
+ const router = useRouter()
11
+
12
+ return (
13
+ <Page maxWidth="xl" gap="l" data-booking-status="on">
14
+ <View gap="m">
15
+ <Text as="h1" variant="heading-primary" text="booking.product.title" />
16
+ <Text style={{ maxWidth: '600px' }} text="booking.product.description" />
17
+ </View>
18
+
19
+ <View gap="m" layout="row-wrap">
20
+ <Button
21
+ {...OpenAvailabilitySetup}
22
+ variant="cta"
23
+ href={router.getHref({ id: 'booking/availability-setup' })}
24
+ />
25
+ <Button
26
+ {...OpenServices}
27
+ variant="primary"
28
+ href={router.getHref({ id: 'booking/services' })}
29
+ />
30
+ <Button
31
+ {...OpenBookings}
32
+ variant="secondary"
33
+ href={router.getHref({ id: 'booking/bookings' })}
34
+ />
35
+ </View>
36
+ </Page>
37
+ )
38
+ }
@@ -0,0 +1,55 @@
1
+ import React, { useState, useCallback } from 'react'
2
+ import { GetWorkspace, EnableService } from '@ossy/workspaces'
3
+ import { useSdk, cacheKey } from '@ossy/sdk-react'
4
+ import { Text, View, Button, Page, useLocale } from '@ossy/design-system'
5
+ import SalesSection from './SalesSection.jsx'
6
+ import { useBookingHomeContent } from './booking-home-content.js'
7
+
8
+ export default function BookingSalesPage ({ isAuthenticated }) {
9
+ const { t } = useLocale()
10
+ const { invoke, invalidate } = useSdk()
11
+ const [error, setError] = useState(null)
12
+ const { cover: baseCover, features } = useBookingHomeContent()
13
+
14
+ const handleEnable = useCallback(() => {
15
+ setError(null)
16
+ invoke(EnableService, { service: '@ossy/booking' })
17
+ .then(() => invalidate(cacheKey(GetWorkspace)))
18
+ .catch(() => setError(t('booking.sales.enableError')))
19
+ }, [invoke, invalidate, t])
20
+
21
+ const enableAction = {
22
+ ...EnableService,
23
+ 'data-service': '@ossy/booking',
24
+ variant: 'cta',
25
+ label: 'booking.sales.enable',
26
+ onClick: handleEnable,
27
+ }
28
+
29
+ const cover = isAuthenticated
30
+ ? {
31
+ ...baseCover,
32
+ actions: [
33
+ enableAction,
34
+ baseCover.actions[1],
35
+ ],
36
+ }
37
+ : baseCover
38
+
39
+ return (
40
+ <View data-booking-status="off">
41
+ <SalesSection cover={cover} features={features} />
42
+
43
+ {isAuthenticated && (
44
+ <Page maxWidth="xl" gap="l">
45
+ <View gap="m" inset="l">
46
+ <Text variant="heading-secondary" as="h2" text="booking.sales.enable" />
47
+ <Text style={{ maxWidth: '600px' }} text="booking.sales.enableDescription" />
48
+ <Button {...enableAction} variant="cta" />
49
+ {error && <Text variant="small">{error}</Text>}
50
+ </View>
51
+ </Page>
52
+ )}
53
+ </View>
54
+ )
55
+ }
package/src/Definition.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export const Definition = {
2
2
  id: 'booking',
3
3
  title: 'Booking',
4
- description: 'Services, availability windows, and client appointment management for consultants.',
4
+ description: 'Services, availability windows, and client appointment management for service providers.',
5
5
  icon: 'calendar',
6
- statuses: ['beta'],
6
+ status: ['beta'],
7
7
  }
package/src/HeroCover.jsx CHANGED
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { Title, Button, View, PageSection } from '@ossy/design-system'
2
+ import { Button, View, PageSection, Text } from '@ossy/design-system'
3
3
 
4
4
  const Cover = ({
5
5
  title,
@@ -19,13 +19,13 @@ const Cover = ({
19
19
  }}
20
20
  >
21
21
  <View gap="m" alignItems="center">
22
- <Title as="h1" variant="display" style={{ maxWidth: titleMaxWidth, textWrap: 'balance' }}>
22
+ <Text as="h1" variant="display" style={{ maxWidth: titleMaxWidth, textWrap: 'balance' }}>
23
23
  {title}
24
- </Title>
24
+ </Text>
25
25
 
26
- <Title as="h2" variant="title-tertiary" style={{ maxWidth: '800px' }}>
26
+ <Text as="h2" variant="heading-tertiary" style={{ maxWidth: '800px' }}>
27
27
  {text}
28
- </Title>
28
+ </Text>
29
29
  </View>
30
30
 
31
31
  <View gap="m" layout="row" justifyContent="center" style={{ flexWrap: 'wrap' }}>
@@ -1,13 +1,5 @@
1
1
  import React from 'react'
2
- import {
3
- Title,
4
- Text,
5
- View,
6
- Icon,
7
- PageSection,
8
- MarkdownViewer,
9
- useLocale,
10
- } from '@ossy/design-system'
2
+ import { Text, View, Icon, PageSection, MarkdownViewer, useLocale } from '@ossy/design-system'
11
3
  import { HeroCover } from './HeroCover.jsx'
12
4
 
13
5
  export default ({
@@ -22,9 +14,9 @@ export default ({
22
14
  <HeroCover {...cover} />
23
15
 
24
16
  <View gap="m" inset="s">
25
- <Title variant="secondary">
17
+ <Text variant="heading-secondary" as="h2">
26
18
  {t('booking.sales.overview')}
27
- </Title>
19
+ </Text>
28
20
 
29
21
  <View gap="m" layout="row-wrap">
30
22
  {features.map(x => (
@@ -50,9 +42,9 @@ export default ({
50
42
  </View>
51
43
 
52
44
  <View gap="m" inset="s">
53
- <Title variant="secondary">
45
+ <Text variant="heading-secondary" as="h2">
54
46
  {t('booking.sales.features')}
55
- </Title>
47
+ </Text>
56
48
 
57
49
  {features.map(x => (
58
50
  <View key={x.title} roundness="m" gap="m" inset="l" style={{ border: '1px solid var(--separator)' }}>
@@ -60,9 +52,9 @@ export default ({
60
52
  <View justifyContent="center" alignItems="center">
61
53
  <Icon name={x.icon} size="m" />
62
54
  </View>
63
- <Title variant="tertiary">
55
+ <Text variant="heading-tertiary" as="h3">
64
56
  {x.title}
65
- </Title>
57
+ </Text>
66
58
  </View>
67
59
 
68
60
  <Text>{x.text}</Text>
@@ -1,5 +1,5 @@
1
1
  import React from 'react'
2
- import { View, Title, Text, Badge } from '@ossy/design-system'
2
+ import { View, Text, Badge } from '@ossy/design-system'
3
3
 
4
4
  const formatPrice = (cents, currency) => {
5
5
  if (cents === 0) return 'Free'
@@ -25,7 +25,7 @@ export const ServiceCard = ({ name, duration, price, currency = 'SEK', descripti
25
25
  >
26
26
  <View gap="xs" style={{ flex: 1 }}>
27
27
  <View layout="row" gap="s" alignItems="center">
28
- <Title variant="secondary">{name}</Title>
28
+ <Text variant="heading-secondary" as="h2">{name}</Text>
29
29
  {!active && <Badge label="Inactive" variant="neutral" size="s" />}
30
30
  </View>
31
31
  {description && <Text size="s" color="secondary">{description}</Text>}
@@ -0,0 +1,39 @@
1
+ import { metadata as ToggleAvailabilityDay0 } from './toggle-availability-day-0.action.js'
2
+ import { metadata as ToggleAvailabilityDay1 } from './toggle-availability-day-1.action.js'
3
+ import { metadata as ToggleAvailabilityDay2 } from './toggle-availability-day-2.action.js'
4
+ import { metadata as ToggleAvailabilityDay3 } from './toggle-availability-day-3.action.js'
5
+ import { metadata as ToggleAvailabilityDay4 } from './toggle-availability-day-4.action.js'
6
+ import { metadata as ToggleAvailabilityDay5 } from './toggle-availability-day-5.action.js'
7
+ import { metadata as ToggleAvailabilityDay6 } from './toggle-availability-day-6.action.js'
8
+ import { metadata as SelectDuration30 } from './select-duration-30.action.js'
9
+ import { metadata as SelectDuration60 } from './select-duration-60.action.js'
10
+ import { metadata as SelectDuration90 } from './select-duration-90.action.js'
11
+
12
+ export {
13
+ ToggleAvailabilityDay0,
14
+ ToggleAvailabilityDay1,
15
+ ToggleAvailabilityDay2,
16
+ ToggleAvailabilityDay3,
17
+ ToggleAvailabilityDay4,
18
+ ToggleAvailabilityDay5,
19
+ ToggleAvailabilityDay6,
20
+ SelectDuration30,
21
+ SelectDuration60,
22
+ SelectDuration90,
23
+ }
24
+
25
+ export const TOGGLE_AVAILABILITY_DAY = {
26
+ 0: ToggleAvailabilityDay0,
27
+ 1: ToggleAvailabilityDay1,
28
+ 2: ToggleAvailabilityDay2,
29
+ 3: ToggleAvailabilityDay3,
30
+ 4: ToggleAvailabilityDay4,
31
+ 5: ToggleAvailabilityDay5,
32
+ 6: ToggleAvailabilityDay6,
33
+ }
34
+
35
+ export const SELECT_DURATION = {
36
+ 30: SelectDuration30,
37
+ 60: SelectDuration60,
38
+ 90: SelectDuration90,
39
+ }
@@ -1,3 +1,5 @@
1
+ import { toMs } from './time.js'
2
+
1
3
  /**
2
4
  * Returns the local date string "YYYY-MM-DD" for a UTC date in the given timezone.
3
5
  */
@@ -42,7 +44,7 @@ function addDays(date, days) {
42
44
  }
43
45
 
44
46
  /**
45
- * Computes available booking slots for a consultant.
47
+ * Computes available booking slots for a provider.
46
48
  *
47
49
  * @param {object} params
48
50
  * @param {object} params.availability
@@ -51,11 +53,11 @@ function addDays(date, days) {
51
53
  * - bufferMinutes: Buffer added after each confirmed booking (default 0)
52
54
  * - blackoutDates: Array of "YYYY-MM-DD" strings to exclude
53
55
  * - timezone: IANA timezone string (e.g. "Europe/Stockholm")
54
- * @param {Array} params.bookings Existing bookings [{ startAt, endAt, status }]
55
- * @param {Date|string} params.fromDate Start of range (inclusive)
56
- * @param {Date|string} params.toDate End of range (inclusive)
56
+ * @param {Array} params.bookings Existing bookings [{ startAt, endAt, status }] — ms or legacy ISO
57
+ * @param {Date|string|number} params.fromDate Start of range (inclusive)
58
+ * @param {Date|string|number} params.toDate End of range (inclusive)
57
59
  * @param {number} params.duration Slot duration in minutes
58
- * @returns {Array<{ startAt: string, endAt: string, duration: number }>}
60
+ * @returns {Array<{ startAt: number, endAt: number, duration: number }>}
59
61
  */
60
62
  export function getAvailableSlots({ availability, bookings = [], fromDate, toDate, duration }) {
61
63
  const {
@@ -65,7 +67,7 @@ export function getAvailableSlots({ availability, bookings = [], fromDate, toDat
65
67
  timezone = 'UTC',
66
68
  } = availability
67
69
 
68
- const now = new Date()
70
+ const now = Date.now()
69
71
  const from = new Date(fromDate)
70
72
  const to = new Date(toDate)
71
73
 
@@ -91,20 +93,22 @@ export function getAvailableSlots({ availability, bookings = [], fromDate, toDat
91
93
  let slotStart = new Date(windowStart)
92
94
 
93
95
  while (slotStart.getTime() + slotMs <= windowEnd.getTime()) {
94
- const slotEnd = new Date(slotStart.getTime() + slotMs)
96
+ const slotEndMs = slotStart.getTime() + slotMs
95
97
 
96
- if (slotEnd > now) {
98
+ if (slotEndMs > now) {
97
99
  const hasOverlap = bookings.some(booking => {
98
100
  if (booking.status === 'cancelled') return false
99
- const bStart = new Date(booking.startAt)
100
- const bEnd = new Date(new Date(booking.endAt).getTime() + bufferMinutes * 60 * 1000)
101
- return slotStart < bEnd && slotEnd > bStart
101
+ const bStart = toMs(booking.startAt)
102
+ const bEnd = toMs(booking.endAt)
103
+ if (bStart == null || bEnd == null) return false
104
+ const bufferedEnd = bEnd + bufferMinutes * 60 * 1000
105
+ return slotStart.getTime() < bufferedEnd && slotEndMs > bStart
102
106
  })
103
107
 
104
108
  if (!hasOverlap) {
105
109
  slots.push({
106
- startAt: slotStart.toISOString(),
107
- endAt: slotEnd.toISOString(),
110
+ startAt: slotStart.getTime(),
111
+ endAt: slotEndMs,
108
112
  duration,
109
113
  })
110
114
  }
@@ -114,5 +118,5 @@ export function getAvailableSlots({ availability, bookings = [], fromDate, toDat
114
118
  }
115
119
  }
116
120
 
117
- return slots.sort((a, b) => (a.startAt > b.startAt ? 1 : -1))
121
+ return slots.sort((a, b) => a.startAt - b.startAt)
118
122
  }
@@ -1,17 +1,17 @@
1
+ import { describe, it, expect, beforeAll, afterAll, jest } from '@jest/globals'
1
2
  import { getAvailableSlots } from './availability-engine.js'
2
3
 
3
4
  // ---------------------------------------------------------------------------
4
5
  // Helpers
5
6
  // ---------------------------------------------------------------------------
6
7
 
7
- /** Build a UTC ISO string for a given date at HH:MM in Europe/Stockholm. */
8
+ /** Build Unix ms for a given date at HH:MM in Europe/Stockholm. */
8
9
  function sthlm(dateStr, timeStr) {
9
- // Use the same localToUTC logic duplicated here to keep tests self-contained
10
10
  const naive = new Date(`${dateStr}T${timeStr}:00Z`)
11
11
  const utcDate = new Date(naive.toLocaleString('en-US', { timeZone: 'UTC' }))
12
12
  const tzDate = new Date(naive.toLocaleString('en-US', { timeZone: 'Europe/Stockholm' }))
13
13
  const offsetMs = utcDate - tzDate
14
- return new Date(naive.getTime() + offsetMs).toISOString()
14
+ return new Date(naive.getTime() + offsetMs).getTime()
15
15
  }
16
16
 
17
17
  const TZ = 'Europe/Stockholm'
@@ -34,6 +34,14 @@ const BASE_AVAILABILITY = {
34
34
  // ---------------------------------------------------------------------------
35
35
 
36
36
  describe('getAvailableSlots', () => {
37
+ beforeAll(() => {
38
+ jest.useFakeTimers()
39
+ jest.setSystemTime(new Date('2024-06-01T12:00:00Z'))
40
+ })
41
+
42
+ afterAll(() => {
43
+ jest.useRealTimers()
44
+ })
37
45
 
38
46
  describe('basic slot generation', () => {
39
47
  it('returns slots at correct interval within the window', () => {
@@ -130,7 +138,7 @@ describe('getAvailableSlots', () => {
130
138
  toDate: new Date(`${MON2}T23:59:59Z`),
131
139
  duration: 60,
132
140
  })
133
- expect(slots.every(s => !s.startAt.startsWith(MON))).toBe(true)
141
+ expect(slots.every(s => s.startAt !== sthlm(MON, '09:00'))).toBe(true)
134
142
  expect(slots.length).toBeGreaterThan(0)
135
143
  })
136
144
  })
@@ -160,6 +168,30 @@ describe('getAvailableSlots', () => {
160
168
  expect(slots[0].startAt).toBe(sthlm(MON, '10:00'))
161
169
  })
162
170
 
171
+ it('accepts legacy ISO booking times', () => {
172
+ const from = new Date(`${MON}T00:00:00Z`)
173
+ const to = new Date(`${MON}T23:59:59Z`)
174
+
175
+ const bookings = [
176
+ {
177
+ startAt: new Date(sthlm(MON, '09:00')).toISOString(),
178
+ endAt: new Date(sthlm(MON, '10:00')).toISOString(),
179
+ status: 'confirmed',
180
+ },
181
+ ]
182
+
183
+ const slots = getAvailableSlots({
184
+ availability: BASE_AVAILABILITY,
185
+ bookings,
186
+ fromDate: from,
187
+ toDate: to,
188
+ duration: 60,
189
+ })
190
+
191
+ expect(slots).toHaveLength(2)
192
+ expect(slots[0].startAt).toBe(sthlm(MON, '10:00'))
193
+ })
194
+
163
195
  it('does NOT remove a slot blocked by a cancelled booking', () => {
164
196
  const from = new Date(`${MON}T00:00:00Z`)
165
197
  const to = new Date(`${MON}T23:59:59Z`)