From 6ea94692a1aaeab3303ef15a9f0065a1d25e9919 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 16 Sep 2026 11:53:22 +0200 Subject: [PATCH 1/4] feat(ui): shadcn range-calendar primitive over reka-ui RangeCalendarRoot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of ui/calendar onto reka-ui's RangeCalendar* parts, same slot shape. The cell trigger gains range states — accent tint for the nights inside the range, primary pills on the two ends — and a real unavailable look (struck through, muted, not a pointer target). Co-Authored-By: Claude Fable 5.1 --- .../ui/range-calendar/RangeCalendar.vue | 58 +++++++++++++++++++ .../ui/range-calendar/RangeCalendarCell.vue | 22 +++++++ .../RangeCalendarCellTrigger.vue | 40 +++++++++++++ .../ui/range-calendar/RangeCalendarGrid.vue | 22 +++++++ .../range-calendar/RangeCalendarGridBody.vue | 12 ++++ .../range-calendar/RangeCalendarGridHead.vue | 13 +++++ .../range-calendar/RangeCalendarGridRow.vue | 19 ++++++ .../range-calendar/RangeCalendarHeadCell.vue | 19 ++++++ .../ui/range-calendar/RangeCalendarHeader.vue | 19 ++++++ .../range-calendar/RangeCalendarHeading.vue | 29 ++++++++++ .../RangeCalendarNextButton.vue | 30 ++++++++++ .../RangeCalendarPrevButton.vue | 30 ++++++++++ src/components/ui/range-calendar/index.ts | 12 ++++ 13 files changed, 325 insertions(+) create mode 100644 src/components/ui/range-calendar/RangeCalendar.vue create mode 100644 src/components/ui/range-calendar/RangeCalendarCell.vue create mode 100644 src/components/ui/range-calendar/RangeCalendarCellTrigger.vue create mode 100644 src/components/ui/range-calendar/RangeCalendarGrid.vue create mode 100644 src/components/ui/range-calendar/RangeCalendarGridBody.vue create mode 100644 src/components/ui/range-calendar/RangeCalendarGridHead.vue create mode 100644 src/components/ui/range-calendar/RangeCalendarGridRow.vue create mode 100644 src/components/ui/range-calendar/RangeCalendarHeadCell.vue create mode 100644 src/components/ui/range-calendar/RangeCalendarHeader.vue create mode 100644 src/components/ui/range-calendar/RangeCalendarHeading.vue create mode 100644 src/components/ui/range-calendar/RangeCalendarNextButton.vue create mode 100644 src/components/ui/range-calendar/RangeCalendarPrevButton.vue create mode 100644 src/components/ui/range-calendar/index.ts diff --git a/src/components/ui/range-calendar/RangeCalendar.vue b/src/components/ui/range-calendar/RangeCalendar.vue new file mode 100644 index 0000000..2d11816 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendar.vue @@ -0,0 +1,58 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarCell.vue b/src/components/ui/range-calendar/RangeCalendarCell.vue new file mode 100644 index 0000000..e5dea17 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarCell.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarCellTrigger.vue b/src/components/ui/range-calendar/RangeCalendarCellTrigger.vue new file mode 100644 index 0000000..7971c55 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarCellTrigger.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarGrid.vue b/src/components/ui/range-calendar/RangeCalendarGrid.vue new file mode 100644 index 0000000..c1db6c9 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarGrid.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarGridBody.vue b/src/components/ui/range-calendar/RangeCalendarGridBody.vue new file mode 100644 index 0000000..68556dd --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarGridBody.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarGridHead.vue b/src/components/ui/range-calendar/RangeCalendarGridHead.vue new file mode 100644 index 0000000..f6cc09e --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarGridHead.vue @@ -0,0 +1,13 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarGridRow.vue b/src/components/ui/range-calendar/RangeCalendarGridRow.vue new file mode 100644 index 0000000..717396c --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarGridRow.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarHeadCell.vue b/src/components/ui/range-calendar/RangeCalendarHeadCell.vue new file mode 100644 index 0000000..7ea377f --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarHeadCell.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarHeader.vue b/src/components/ui/range-calendar/RangeCalendarHeader.vue new file mode 100644 index 0000000..5cef2d5 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarHeader.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarHeading.vue b/src/components/ui/range-calendar/RangeCalendarHeading.vue new file mode 100644 index 0000000..90845e3 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarHeading.vue @@ -0,0 +1,29 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarNextButton.vue b/src/components/ui/range-calendar/RangeCalendarNextButton.vue new file mode 100644 index 0000000..f60c786 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarNextButton.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/ui/range-calendar/RangeCalendarPrevButton.vue b/src/components/ui/range-calendar/RangeCalendarPrevButton.vue new file mode 100644 index 0000000..8ed2032 --- /dev/null +++ b/src/components/ui/range-calendar/RangeCalendarPrevButton.vue @@ -0,0 +1,30 @@ + + + diff --git a/src/components/ui/range-calendar/index.ts b/src/components/ui/range-calendar/index.ts new file mode 100644 index 0000000..34d5361 --- /dev/null +++ b/src/components/ui/range-calendar/index.ts @@ -0,0 +1,12 @@ +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" From 7dbabfaa81e59a264bfa2e0ce8f22aa2f6717845 Mon Sep 17 00:00:00 2001 From: Padreug Date: Wed, 16 Sep 2026 11:53:22 +0200 Subject: [PATCH 2/4] feat(chatelet): availability calendar with Airbnb-style stay picking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two date pickers and the manual 'Check availability' button with one range calendar fed by the extension's keyless unavailable-ranges endpoint (chatelet ≥ v0.5.0): occupied nights are struck through, a stay can never straddle one, and check-out may land on the morning another guest arrives (half-open nights). Two months on sm+, one on phones. reka-ui only guards clicks with isDateDisabled and hides selected styling on isDateUnavailable days, so the matchers are selection-aware: once a check-in is chosen the first occupied night after it stops being unavailable (it is the last legal check-out) and every later day is disabled; min_nights is enforced the same way. Both matchers close over refs because reka reads them once at setup. Picking a complete stay quotes it automatically; the quote card shows rate × nights and the total. A quote that contradicts the local calendar refreshes it (someone else's hold appeared), as does a confirmed booking. The /login?redirect round-trip of dates is unchanged. Co-Authored-By: Claude Fable 5.1 --- .../chatelet/components/StayDatePicker.vue | 260 ++++++++++++++++++ .../composables/useRoomUnavailability.ts | 48 ++++ src/modules/chatelet/lib/unavailable.spec.ts | 50 ++++ src/modules/chatelet/lib/unavailable.ts | 49 ++++ .../chatelet/services/ChateletApiService.ts | 22 +- src/modules/chatelet/types/room.ts | 14 + .../chatelet/views/ChateletDetailPage.vue | 114 ++++---- 7 files changed, 502 insertions(+), 55 deletions(-) create mode 100644 src/modules/chatelet/components/StayDatePicker.vue create mode 100644 src/modules/chatelet/composables/useRoomUnavailability.ts create mode 100644 src/modules/chatelet/lib/unavailable.spec.ts create mode 100644 src/modules/chatelet/lib/unavailable.ts 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 @@ + + + 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… +
+ +