diff --git a/src/components/ui/range-calendar/RangeCalendar.vue b/src/components/ui/range-calendar/RangeCalendar.vue deleted file mode 100644 index 2d11816..0000000 --- a/src/components/ui/range-calendar/RangeCalendar.vue +++ /dev/null @@ -1,58 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/RangeCalendarCell.vue b/src/components/ui/range-calendar/RangeCalendarCell.vue deleted file mode 100644 index e5dea17..0000000 --- a/src/components/ui/range-calendar/RangeCalendarCell.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/RangeCalendarCellTrigger.vue b/src/components/ui/range-calendar/RangeCalendarCellTrigger.vue deleted file mode 100644 index 7971c55..0000000 --- a/src/components/ui/range-calendar/RangeCalendarCellTrigger.vue +++ /dev/null @@ -1,40 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/RangeCalendarGrid.vue b/src/components/ui/range-calendar/RangeCalendarGrid.vue deleted file mode 100644 index c1db6c9..0000000 --- a/src/components/ui/range-calendar/RangeCalendarGrid.vue +++ /dev/null @@ -1,22 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/RangeCalendarGridBody.vue b/src/components/ui/range-calendar/RangeCalendarGridBody.vue deleted file mode 100644 index 68556dd..0000000 --- a/src/components/ui/range-calendar/RangeCalendarGridBody.vue +++ /dev/null @@ -1,12 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/RangeCalendarGridHead.vue b/src/components/ui/range-calendar/RangeCalendarGridHead.vue deleted file mode 100644 index f6cc09e..0000000 --- a/src/components/ui/range-calendar/RangeCalendarGridHead.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/RangeCalendarGridRow.vue b/src/components/ui/range-calendar/RangeCalendarGridRow.vue deleted file mode 100644 index 717396c..0000000 --- a/src/components/ui/range-calendar/RangeCalendarGridRow.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/RangeCalendarHeadCell.vue b/src/components/ui/range-calendar/RangeCalendarHeadCell.vue deleted file mode 100644 index 7ea377f..0000000 --- a/src/components/ui/range-calendar/RangeCalendarHeadCell.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/RangeCalendarHeader.vue b/src/components/ui/range-calendar/RangeCalendarHeader.vue deleted file mode 100644 index 5cef2d5..0000000 --- a/src/components/ui/range-calendar/RangeCalendarHeader.vue +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/RangeCalendarHeading.vue b/src/components/ui/range-calendar/RangeCalendarHeading.vue deleted file mode 100644 index 90845e3..0000000 --- a/src/components/ui/range-calendar/RangeCalendarHeading.vue +++ /dev/null @@ -1,29 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/RangeCalendarNextButton.vue b/src/components/ui/range-calendar/RangeCalendarNextButton.vue deleted file mode 100644 index f60c786..0000000 --- a/src/components/ui/range-calendar/RangeCalendarNextButton.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/RangeCalendarPrevButton.vue b/src/components/ui/range-calendar/RangeCalendarPrevButton.vue deleted file mode 100644 index 8ed2032..0000000 --- a/src/components/ui/range-calendar/RangeCalendarPrevButton.vue +++ /dev/null @@ -1,30 +0,0 @@ - - - diff --git a/src/components/ui/range-calendar/index.ts b/src/components/ui/range-calendar/index.ts deleted file mode 100644 index 34d5361..0000000 --- a/src/components/ui/range-calendar/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -export { default as RangeCalendar } from "./RangeCalendar.vue" -export { default as RangeCalendarCell } from "./RangeCalendarCell.vue" -export { default as RangeCalendarCellTrigger } from "./RangeCalendarCellTrigger.vue" -export { default as RangeCalendarGrid } from "./RangeCalendarGrid.vue" -export { default as RangeCalendarGridBody } from "./RangeCalendarGridBody.vue" -export { default as RangeCalendarGridHead } from "./RangeCalendarGridHead.vue" -export { default as RangeCalendarGridRow } from "./RangeCalendarGridRow.vue" -export { default as RangeCalendarHeadCell } from "./RangeCalendarHeadCell.vue" -export { default as RangeCalendarHeader } from "./RangeCalendarHeader.vue" -export { default as RangeCalendarHeading } from "./RangeCalendarHeading.vue" -export { default as RangeCalendarNextButton } from "./RangeCalendarNextButton.vue" -export { default as RangeCalendarPrevButton } from "./RangeCalendarPrevButton.vue" diff --git a/src/modules/chatelet/components/StayDatePicker.vue b/src/modules/chatelet/components/StayDatePicker.vue deleted file mode 100644 index 3c2ee0e..0000000 --- a/src/modules/chatelet/components/StayDatePicker.vue +++ /dev/null @@ -1,260 +0,0 @@ - - - diff --git a/src/modules/chatelet/composables/useRoomUnavailability.ts b/src/modules/chatelet/composables/useRoomUnavailability.ts deleted file mode 100644 index 684b931..0000000 --- a/src/modules/chatelet/composables/useRoomUnavailability.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index 2756ce6..0000000 --- a/src/modules/chatelet/lib/unavailable.spec.ts +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 0904b4c..0000000 --- a/src/modules/chatelet/lib/unavailable.ts +++ /dev/null @@ -1,49 +0,0 @@ -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 36e74f3..2be1400 100644 --- a/src/modules/chatelet/services/ChateletApiService.ts +++ b/src/modules/chatelet/services/ChateletApiService.ts @@ -1,4 +1,4 @@ -import type { AvailabilityResult, Room, UnavailableRanges } from '../types/room' +import type { AvailabilityResult, Room } from '../types/room' import type { BookingQuote, BookingRequest, PublicBooking } from '../types/booking' export interface ChateletApiConfig { @@ -30,26 +30,6 @@ 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 b49ebef..6800d6a 100644 --- a/src/modules/chatelet/types/room.ts +++ b/src/modules/chatelet/types/room.ts @@ -31,17 +31,3 @@ 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 aa71891..ae43195 100644 --- a/src/modules/chatelet/views/ChateletDetailPage.vue +++ b/src/modules/chatelet/views/ChateletDetailPage.vue @@ -2,18 +2,16 @@ import { computed, onMounted, ref, watch } from 'vue' import { useRoute, useRouter } from 'vue-router' import { toast } from 'vue-sonner' -import { watchDebounced } from '@vueuse/core' +import { addDays, format } from 'date-fns' 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 { formatSats } from '@/lib/utils/formatting' +import { Label } from '@/components/ui/label' +import DatePicker from '@/modules/base/components/DatePicker.vue' 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() @@ -32,23 +30,25 @@ const showBooking = ref(false) * `result` being cleared once the dates are taken. */ const bookingQuote = ref(null) -// 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) +// 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')) -// 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], () => { +// 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, +) + +watch(checkIn, () => { + if (checkOut.value && checkOut.value < minCheckOut.value) checkOut.value = '' + result.value = null +}) +watch(checkOut, () => { result.value = null }) -watchDebounced( - [checkIn, checkOut], - ([ci, co]) => { - if (ci && co && room.value) void onCheck() - }, - { debounce: 150 }, -) const YMD = /^\d{4}-\d{2}-\d{2}$/ @@ -66,6 +66,7 @@ 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() } }) @@ -74,16 +75,7 @@ async function onCheck() { checking.value = true result.value = null try { - 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() - } + result.value = await checkAvailability(room.value.id, checkIn.value, checkOut.value) } catch (e: unknown) { toast.error(e instanceof Error ? e.message : 'Availability check failed') } finally { @@ -110,12 +102,11 @@ function onBook() { showBooking.value = true } -/** The dates are taken now — drop the stale quote and grey them out. */ +/** The dates are taken now — drop the stale quote so the card resets. */ function onBooked() { result.value = null checkIn.value = '' checkOut.value = '' - void unavailability.refresh() } watch(showBooking, (open) => { @@ -162,36 +153,39 @@ watch(showBooking, (open) => {

- - -

- Could not load the availability calendar — you can still pick dates and check them. -

- -
-
- Checking availability… + +
+
+ + +
+
+ + +
+ -
+