diff --git a/src/modules/chatelet/components/StayDatePicker.vue b/src/modules/chatelet/components/StayDatePicker.vue
new file mode 100644
index 0000000..3c2ee0e
--- /dev/null
+++ b/src/modules/chatelet/components/StayDatePicker.vue
@@ -0,0 +1,260 @@
+
+
+
+
+
+
+
+
{{ title }}
+
{{ subtitle }}
+
+
+
+
Check-in
+
+
+ {{ checkIn ? pretty(checkIn) : 'Add date' }}
+
+
+
+
+
+
Check-out
+
+
+ {{ checkOut ? pretty(checkOut) : 'Add date' }}
+
+
+
+
+
+
+
+
+
+
+
+
+ Unavailable
+
+
+
+
+
diff --git a/src/modules/chatelet/composables/useRoomUnavailability.ts b/src/modules/chatelet/composables/useRoomUnavailability.ts
new file mode 100644
index 0000000..684b931
--- /dev/null
+++ b/src/modules/chatelet/composables/useRoomUnavailability.ts
@@ -0,0 +1,48 @@
+import { computed, ref, watch, type Ref } from 'vue'
+import { getLocalTimeZone, today } from '@internationalized/date'
+import { SERVICE_TOKENS, tryInjectService } from '@/core/di-container'
+import type { ChateletApiService } from '../services/ChateletApiService'
+import { addDays, expandRanges } from '../lib/unavailable'
+
+const WINDOW_DAYS = 365
+
+/**
+ * The occupied nights of one room for the calendar. One fetch of a 12-month
+ * window per room — a few spans, cheap — and the calendar's `maxValue` is
+ * pinned to the window end, so no refetch on month navigation. `refresh()`
+ * after a booking lands, or when a quote comes back "not available" for a
+ * stay the local set thought free (someone else's hold appeared).
+ */
+export function useRoomUnavailability(roomId: Ref) {
+ // DI, never a direct import (workspace rule).
+ const api = tryInjectService(SERVICE_TOKENS.CHATELET_API)
+
+ const nights = ref([])
+ const windowStart = ref(today(getLocalTimeZone()).toString())
+ const windowEnd = ref(addDays(windowStart.value, WINDOW_DAYS))
+ const isLoading = ref(false)
+ const error = ref(null)
+
+ const nightSet = computed(() => new Set(nights.value))
+
+ async function refresh(): Promise {
+ if (!api || !roomId.value) return
+ isLoading.value = true
+ error.value = null
+ try {
+ const start = today(getLocalTimeZone()).toString()
+ const res = await api.getUnavailableRanges(roomId.value, start, addDays(start, WINDOW_DAYS))
+ windowStart.value = res.start
+ windowEnd.value = res.end
+ nights.value = expandRanges(res.ranges)
+ } catch (e: unknown) {
+ error.value = e instanceof Error ? e.message : 'Could not load availability'
+ } finally {
+ isLoading.value = false
+ }
+ }
+
+ watch(roomId, () => void refresh(), { immediate: true })
+
+ return { nights, nightSet, windowStart, windowEnd, isLoading, error, refresh }
+}
diff --git a/src/modules/chatelet/lib/unavailable.spec.ts b/src/modules/chatelet/lib/unavailable.spec.ts
new file mode 100644
index 0000000..2756ce6
--- /dev/null
+++ b/src/modules/chatelet/lib/unavailable.spec.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from 'vitest'
+import {
+ addDays,
+ expandRanges,
+ nextUnavailableOnOrAfter,
+ nightsBetween,
+ stayIsFree,
+} from './unavailable'
+
+describe('unavailable-night helpers', () => {
+ it('addDays crosses month and year boundaries', () => {
+ expect(addDays('2026-10-31', 1)).toBe('2026-11-01')
+ expect(addDays('2026-12-31', 1)).toBe('2027-01-01')
+ expect(addDays('2026-03-01', -1)).toBe('2026-02-28')
+ })
+
+ it('nightsBetween is check_out-exclusive', () => {
+ expect(nightsBetween('2026-10-05', '2026-10-07')).toBe(2)
+ expect(nightsBetween('2026-10-05', '2026-10-05')).toBe(0)
+ })
+
+ it('expandRanges yields sorted, de-duplicated occupied nights (end exclusive)', () => {
+ const nights = expandRanges([
+ { start: '2026-10-10', end: '2026-10-12' },
+ { start: '2026-10-05', end: '2026-10-07' },
+ { start: '2026-10-11', end: '2026-10-13' },
+ ])
+ expect(nights).toEqual(['2026-10-05', '2026-10-06', '2026-10-10', '2026-10-11', '2026-10-12'])
+ })
+
+ it('nextUnavailableOnOrAfter finds the boundary night', () => {
+ const nights = ['2026-10-05', '2026-10-06', '2026-10-10']
+ expect(nextUnavailableOnOrAfter(nights, '2026-10-01')).toBe('2026-10-05')
+ expect(nextUnavailableOnOrAfter(nights, '2026-10-05')).toBe('2026-10-05')
+ expect(nextUnavailableOnOrAfter(nights, '2026-10-07')).toBe('2026-10-10')
+ expect(nextUnavailableOnOrAfter(nights, '2026-10-11')).toBeUndefined()
+ expect(nextUnavailableOnOrAfter([], '2026-10-11')).toBeUndefined()
+ })
+
+ it('stayIsFree honours half-open semantics', () => {
+ const set = new Set(['2026-10-05', '2026-10-06'])
+ // checking out the morning the other guest checks in is fine
+ expect(stayIsFree(set, '2026-10-03', '2026-10-05')).toBe(true)
+ // checking in the morning they leave is fine too
+ expect(stayIsFree(set, '2026-10-07', '2026-10-09')).toBe(true)
+ // spanning an occupied night is not
+ expect(stayIsFree(set, '2026-10-04', '2026-10-06')).toBe(false)
+ expect(stayIsFree(set, '2026-10-06', '2026-10-06')).toBe(false)
+ })
+})
diff --git a/src/modules/chatelet/lib/unavailable.ts b/src/modules/chatelet/lib/unavailable.ts
new file mode 100644
index 0000000..0904b4c
--- /dev/null
+++ b/src/modules/chatelet/lib/unavailable.ts
@@ -0,0 +1,49 @@
+import type { DateRange } from '../types/room'
+
+/** YYYY-MM-DD → YYYY-MM-DD, `days` later. Date-only math, no timezone. */
+export function addDays(ymd: string, days: number): string {
+ const d = new Date(ymd + 'T00:00:00Z')
+ d.setUTCDate(d.getUTCDate() + days)
+ return d.toISOString().slice(0, 10)
+}
+
+/** Nights between two YYYY-MM-DD dates (check_out exclusive). */
+export function nightsBetween(checkIn: string, checkOut: string): number {
+ const a = Date.UTC(+checkIn.slice(0, 4), +checkIn.slice(5, 7) - 1, +checkIn.slice(8, 10))
+ const b = Date.UTC(+checkOut.slice(0, 4), +checkOut.slice(5, 7) - 1, +checkOut.slice(8, 10))
+ return Math.round((b - a) / 86_400_000)
+}
+
+/**
+ * Expand half-open [start, end) spans into the sorted, de-duplicated list of
+ * occupied nights. A year of a busy room is a few hundred strings — cheap to
+ * hold and to binary-search.
+ */
+export function expandRanges(ranges: DateRange[]): string[] {
+ const out = new Set()
+ for (const r of ranges) {
+ for (let d = r.start; d < r.end; d = addDays(d, 1)) out.add(d)
+ }
+ return [...out].sort()
+}
+
+/** First occupied night ≥ `ymd`, or undefined if none in the loaded window. */
+export function nextUnavailableOnOrAfter(nights: string[], ymd: string): string | undefined {
+ let lo = 0
+ let hi = nights.length
+ while (lo < hi) {
+ const mid = (lo + hi) >> 1
+ if (nights[mid] < ymd) lo = mid + 1
+ else hi = mid
+ }
+ return nights[lo]
+}
+
+/** True when every night of [checkIn, checkOut) is free. */
+export function stayIsFree(nightSet: Set, checkIn: string, checkOut: string): boolean {
+ if (!(checkIn < checkOut)) return false
+ for (let d = checkIn; d < checkOut; d = addDays(d, 1)) {
+ if (nightSet.has(d)) return false
+ }
+ return true
+}
diff --git a/src/modules/chatelet/services/ChateletApiService.ts b/src/modules/chatelet/services/ChateletApiService.ts
index 2be1400..36e74f3 100644
--- a/src/modules/chatelet/services/ChateletApiService.ts
+++ b/src/modules/chatelet/services/ChateletApiService.ts
@@ -1,4 +1,4 @@
-import type { AvailabilityResult, Room } from '../types/room'
+import type { AvailabilityResult, Room, UnavailableRanges } from '../types/room'
import type { BookingQuote, BookingRequest, PublicBooking } from '../types/booking'
export interface ChateletApiConfig {
@@ -30,6 +30,26 @@ export class ChateletApiService {
return this.request(`/chatelet/api/v1/public/rooms/${id}`, { method: 'GET' })
}
+ /**
+ * Nights a guest cannot book over a window (default today → +365 d): live
+ * holds, confirmed stays and manual blocks merged into anonymous spans.
+ * Keyless, like the room read it hangs off.
+ */
+ async getUnavailableRanges(
+ roomId: string,
+ start?: string,
+ end?: string,
+ ): Promise {
+ const q = new URLSearchParams()
+ if (start) q.set('start', start)
+ if (end) q.set('end', end)
+ const qs = q.toString()
+ return this.request(
+ `/chatelet/api/v1/public/rooms/${roomId}/unavailable${qs ? `?${qs}` : ''}`,
+ { method: 'GET' },
+ )
+ }
+
async checkAvailability(
roomId: string,
checkIn: string,
diff --git a/src/modules/chatelet/types/room.ts b/src/modules/chatelet/types/room.ts
index 6800d6a..b49ebef 100644
--- a/src/modules/chatelet/types/room.ts
+++ b/src/modules/chatelet/types/room.ts
@@ -31,3 +31,17 @@ export interface AvailabilityResult {
quote_fiat: number | null
currency: string | null
}
+
+/** Half-open [start, end) span of occupied nights; `end` is the morning the room frees up. */
+export interface DateRange {
+ start: string
+ end: string
+}
+
+/** `GET /chatelet/api/v1/public/rooms/{id}/unavailable` — the guest calendar feed. */
+export interface UnavailableRanges {
+ room_id: string
+ start: string
+ end: string
+ ranges: DateRange[]
+}
diff --git a/src/modules/chatelet/views/ChateletDetailPage.vue b/src/modules/chatelet/views/ChateletDetailPage.vue
index ae43195..aa71891 100644
--- a/src/modules/chatelet/views/ChateletDetailPage.vue
+++ b/src/modules/chatelet/views/ChateletDetailPage.vue
@@ -2,16 +2,18 @@
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
-import { addDays, format } from 'date-fns'
+import { watchDebounced } from '@vueuse/core'
import { MapPin, Users } from 'lucide-vue-next'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
-import { Label } from '@/components/ui/label'
-import DatePicker from '@/modules/base/components/DatePicker.vue'
+import { formatSats } from '@/lib/utils/formatting'
import { useAuth } from '@/composables/useAuthService'
import { useChatelet } from '../composables/useChatelet'
+import { useRoomUnavailability } from '../composables/useRoomUnavailability'
+import { stayIsFree } from '../lib/unavailable'
import BookRoomDialog from '../components/BookRoomDialog.vue'
+import StayDatePicker from '../components/StayDatePicker.vue'
import type { AvailabilityResult, Room } from '../types/room'
const route = useRoute()
@@ -30,26 +32,24 @@ const showBooking = ref(false)
* `result` being cleared once the dates are taken. */
const bookingQuote = ref(null)
-// Same YYYY-MM-DD wire format the DatePicker speaks; the availability
-// endpoint takes the same strings.
-const today = computed(() => format(new Date(), 'yyyy-MM-dd'))
+// Occupied nights for the calendar; refreshed after a booking and when a
+// quote contradicts the local set (someone else's hold appeared).
+const roomIdRef = computed(() => route.params.id as string | undefined)
+const unavailability = useRoomUnavailability(roomIdRef)
-// Check-out must be at least the next morning. Bumping the floor also
-// clears a check-out that the new check-in has overtaken.
-const minCheckOut = computed(() =>
- checkIn.value
- ? format(addDays(new Date(checkIn.value + 'T00:00:00'), 1), 'yyyy-MM-dd')
- : today.value,
+// Any date change invalidates the last quote; a complete stay re-quotes
+// itself (the availability call is the price preview, so no button).
+watch([checkIn, checkOut], () => {
+ result.value = null
+})
+watchDebounced(
+ [checkIn, checkOut],
+ ([ci, co]) => {
+ if (ci && co && room.value) void onCheck()
+ },
+ { debounce: 150 },
)
-watch(checkIn, () => {
- if (checkOut.value && checkOut.value < minCheckOut.value) checkOut.value = ''
- result.value = null
-})
-watch(checkOut, () => {
- result.value = null
-})
-
const YMD = /^\d{4}-\d{2}-\d{2}$/
onMounted(async () => {
@@ -66,7 +66,6 @@ onMounted(async () => {
if (typeof qi === 'string' && YMD.test(qi) && typeof qo === 'string' && YMD.test(qo)) {
checkIn.value = qi
checkOut.value = qo
- if (room.value) await onCheck()
}
})
@@ -75,7 +74,16 @@ async function onCheck() {
checking.value = true
result.value = null
try {
- result.value = await checkAvailability(room.value.id, checkIn.value, checkOut.value)
+ const ci = checkIn.value
+ const co = checkOut.value
+ const r = await checkAvailability(room.value.id, ci, co)
+ // Only the quote for the dates still selected counts (debounce race).
+ if (ci !== checkIn.value || co !== checkOut.value) return
+ result.value = r
+ if (r && !r.available && stayIsFree(unavailability.nightSet.value, ci, co)) {
+ // The calendar thought these nights were free — it is stale.
+ void unavailability.refresh()
+ }
} catch (e: unknown) {
toast.error(e instanceof Error ? e.message : 'Availability check failed')
} finally {
@@ -102,11 +110,12 @@ function onBook() {
showBooking.value = true
}
-/** The dates are taken now — drop the stale quote so the card resets. */
+/** The dates are taken now — drop the stale quote and grey them out. */
function onBooked() {
result.value = null
checkIn.value = ''
checkOut.value = ''
+ void unavailability.refresh()
}
watch(showBooking, (open) => {
@@ -153,39 +162,36 @@ watch(showBooking, (open) => {
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+ Could not load the availability calendar — you can still pick dates and check them.
+
-
+
+
+ Checking availability…
+
+
+
-
- Available for {{ result.nights }} night(s) —
- {{ result.quote_sat }} sats
-
- (≈ {{ result.quote_fiat }} {{ result.currency }})
-
-
+
+
+ {{ room.price_amount }} {{ room.price_currency }} × {{ result.nights }} night{{ result.nights === 1 ? '' : 's' }}
+ {{ result.quote_fiat }} {{ result.currency }}
+
+
+ Total
+ {{ formatSats(result.quote_sat) }}
+
+