Files
mp-pilates/BOOKING_PAGE_ANALYSIS.md
richarjiang b6986ba30c feat(admin): implement full day-by-day schedule editor with live preview
## Features

### Admin Schedule Page (`packages/app/src/pages/admin/schedule.vue`)
- Interactive date-based slot editor for managing daily schedules
- Real-time slot editing: start/end times, capacity adjustments
- Slot deletion with conflict warnings when bookings exist
- Add new slots with modal dialog
- Live booking status display (booked count, people names)
- Publish/Save changes with sync feedback
- Revert unsaved changes with confirmation
- Skeleton loading states and empty state handling
- Responsive design with optimized mobile UX

### Backend Enhancements
- **New DTO** (`PublishDaySlotsDto`): Structured slot publishing with validation
  - Date string validation
  - Slot array with existing slot IDs for updates
  - Time and capacity validation per slot

- **Schedule Preview API** (`getSchedulePreview`):
  - Check for existing published slots
  - Fallback to active WeekTemplates for unpublished dates
  - Unified response format with isPublished flag

- **Publish Slots API** (`publishDaySlots`):
  - Atomic transaction for consistency
  - Update existing slots with new times/capacity
  - Create new slots from template data
  - Delete unpublished slots or set to CLOSED if bookings exist
  - Prevent capacity reduction below existing bookings
  - Returns all published slots for feedback

### State Management
- Enhanced admin store with schedule state
- Support for pending/unsaved slot changes
- Optimistic UI updates with server sync

### Documentation
- Comprehensive scheduling system architecture docs
- Quick reference for admin workflows
- Flow diagrams and state transitions
- Implementation guide for future maintenance

## Breaking Changes
None

## Testing Recommendations
- Create slots for future dates via schedule editor
- Verify booking prevention for locked/full slots
- Test capacity adjustments with existing bookings
- Confirm template-based schedule generation
- Verify transaction rollback on publish failures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 12:18:49 +08:00

895 lines
23 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WeChat Mini-Program Booking Page Analysis
## mp-pilates Project (Uni-app + Vue 3)
---
## 📋 Project Structure Overview
```
packages/app/src/
├── pages/
│ └── booking/
│ └── index.vue # 📍 Main booking page
├── components/
│ ├── DateSelector.vue # Date picker (7 days)
│ ├── TimePeriodFilter.vue # Morning/Afternoon/Evening filter
│ ├── SlotCard.vue # Individual time slot card
│ └── BookingConfirmPopup.vue # Confirmation modal
├── stores/
│ ├── booking.ts # 📍 Booking state management
│ └── user.ts # User/membership state
└── utils/
├── request.ts # API request utilities
└── format.ts # Date/time formatting utilities
```
---
## 🎯 API Flow
### Endpoint: `/api/time-slot/available?date=YYYY-MM-DD`
**Request:**
- Method: `GET`
- Query params: `date` (YYYY-MM-DD format)
- Authentication: Bearer token from localStorage
**Response Format (from your example):**
```json
{
"success": true,
"data": [
{
"id": "string (UUID)",
"date": "2026-04-05",
"startTime": "09:00",
"endTime": "10:00",
"capacity": 1,
"bookedCount": 0,
"status": "OPEN",
"source": "MANUAL",
"templateId": null,
"isBookedByMe": false,
"myBookingId": null
}
],
"message": null
}
```
**Status Values:**
- `OPEN` - Available to book
- `FULL` - All slots booked
- `CLOSED` - Time slot closed
**Source Values:**
- `MANUAL` - Manually created
- `TEMPLATE` - Generated from template
---
## 🔄 Complete Data Flow Diagram
```
User Opens Booking Page
[onMounted] Lifecycle Hook
1. Check if logged in + fetch memberships (if needed)
2. Load today's slots: bookingStore.fetchSlots(today)
bookingStore.fetchSlots(date: string)
request.get<TimeSlotWithBookingStatus[]>(
'/time-slot/available',
{ date }
)
Sets: bookingStore.slots = [TimeSlotWithBookingStatus[], ...]
Vue renders via computed: filteredSlots
User selects date OR filters by time period
Updates: selectedDate.value or selectedPeriod.value
Computed filteredSlots re-calculates
Renders SlotCard components
User taps "可预约" (Book Button)
[onBookTap(slot)]
- Check login (if not → show login modal)
- Check valid membership (if not → show purchase modal)
- Show BookingConfirmPopup
User selects membership + confirms
[onConfirmBooking(payload)]
- bookingStore.createBooking({timeSlotId, membershipId})
- POST /api/booking
- Refresh slots: loadSlots(selectedDate.value)
Success/Error Toast
```
---
## 📄 File-by-File Analysis
### 1⃣ **pages/booking/index.vue** (Main Component)
**Template Structure:**
```
.booking-page
├── .sticky-header (z-index: 100)
│ ├── DateSelector (v-model="selectedDate")
│ └── TimePeriodFilter (v-model="selectedPeriod")
├── scroll-view.slot-scroll
│ ├── Loading skeleton (4 cards) - when loadingSlots
│ ├── Empty state - when no slots
│ └── SlotCard list - main content
│ └── SlotCard (v-for="slot in filteredSlots")
└── BookingConfirmPopup (conditional)
```
**Script Setup - State Variables:**
```typescript
selectedDate: ref<string> // YYYY-MM-DD format
selectedPeriod: ref<PeriodKey> // 'MORNING'|'AFTERNOON'|'EVENING'|null
showConfirmPopup: ref<boolean> // Modal visibility
pendingSlot: ref<Slot | null> // Slot being booked
refreshing: ref<boolean> // Pull-to-refresh state
```
**Computed Properties:**
```typescript
scrollHeight: computed(() => {
// Calculates scroll area height:
// windowHeight - headerHeight (220rpx) - tabbarHeight (100rpx)
// Converts rpx to pixels dynamically
})
filteredSlots: computed(() => {
// If no period selected: return all slots
// If period selected: filter by TIME_PERIODS[selectedPeriod].start/.end
// Compares slot.startTime with period.start and period.end
})
```
**Key Lifecycle - onMounted():**
```typescript
1. If logged in but no memberships fetched yet:
await userStore.fetchMemberships()
2. Load today's slots:
await loadSlots(formatDate(new Date()))
```
**Event Handlers:**
**onDateSelect(date: string)** → Changes selectedDate, calls loadSlots()
**onPeriodChange(period)** → Updates selectedPeriod (filtering is automatic via computed)
**onRefresh()** → Pull-to-refresh handler
```typescript
refreshing.value = true
await loadSlots(selectedDate.value)
refreshing.value = false
```
**onBookTap(slot)** → Book button clicked:
1. Check login status → show login modal if needed
2. Check hasValidMembership → show purchase modal if needed
3. Set pendingSlot = slot
4. Show BookingConfirmPopup
**onConfirmBooking(payload)** → User confirms booking:
```typescript
await bookingStore.createBooking(payload)
// payload: { timeSlotId, membershipId }
await loadSlots(selectedDate.value) // Refresh
```
**onCancelTap(slot)** → Cancel booking:
```typescript
if (!slot.myBookingId) return
// Show confirmation modal
await bookingStore.cancelBooking(slot.myBookingId)
await loadSlots(selectedDate.value) // Refresh
```
**Styles:**
- Page background: `#f5f3f0` (light beige)
- Sticky header with box-shadow
- Loading skeleton with shimmer animation
- Empty state centered with image
---
### 2⃣ **stores/booking.ts** (State Management)
**State:**
```typescript
slots: ref<readonly TimeSlotWithBookingStatus[]>([])
myBookings: ref<readonly BookingWithDetails[]>([])
upcomingBookings: ref<readonly BookingWithDetails[]>([])
loadingSlots: ref<boolean>(false)
loadingBookings: ref<boolean>(false)
```
**Actions:**
**fetchSlots(date: string)**
```typescript
async function fetchSlots(date: string) {
loadingSlots.value = true
try {
slots.value = await get<TimeSlotWithBookingStatus[]>(
'/time-slot/available',
{ date } // ← date as query param
)
} catch (err) {
console.error('Fetch slots failed:', err)
slots.value = []
} finally {
loadingSlots.value = false
}
}
```
⚠️ **CRITICAL:** If request fails, slots.value becomes empty []
**createBooking(dto: CreateBookingDto)**
```typescript
// dto: { timeSlotId: string; membershipId: string }
const result = await post<BookingWithDetails>('/booking', dto)
return result
```
**cancelBooking(bookingId: string)**
```typescript
const result = await put<BookingWithDetails>(`/booking/${bookingId}/cancel`)
return result
```
**fetchMyBookings(status?: string)**
```typescript
const params = status ? { status } : {}
myBookings.value = await get<BookingWithDetails[]>('/booking/my', params)
```
**fetchUpcomingBookings()**
```typescript
upcomingBookings.value = await get<BookingWithDetails[]>('/booking/my/upcoming')
```
---
### 3⃣ **components/SlotCard.vue** (Individual Slot)
**Props:**
```typescript
interface Props {
slot: TimeSlotWithBookingStatus
}
```
**Emits:**
```typescript
book: [slot] // User wants to book
cancel: [slot] // User wants to cancel
```
**Template Sections:**
**1. Time & Capacity:**
```vue
<text>{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}</text>
<!-- e.g., "09:00 - 10:00" -->
<view class="slot-capacity" :class="capacityClass">
{{ capacityLabel }}
</view>
```
**2. Action Buttons (4 States):**
**State A: OPEN + not booked by me**
```vue
<view class="btn btn-book">可预约</view>
<!-- Tan/brown button, emits: book -->
```
**State B: OPEN + booked by me**
```vue
<view class="badge-booked">已预约</view>
<view class="btn-cancel">取消</view>
<!-- Badge + cancel link, emits: cancel -->
```
**State C: FULL**
```vue
<view class="btn btn-disabled">已约满</view>
<!-- Gray disabled button -->
```
**State D: CLOSED**
```vue
<view class="btn btn-disabled">已关闭</view>
<!-- Gray disabled button -->
```
**3. Booked Indicator:**
```vue
<view v-if="slot.isBookedByMe" class="booked-bar" />
<!-- Tan bar on left side of card when booked by me -->
```
**Computed Properties:**
**capacityLabel:**
```typescript
if (status === CLOSED) return '已关闭'
return `${bookedCount}/${capacity}` // e.g., "0/1 人"
```
**capacityClass:** Determines background color
```
CLOSED → cap-closed (gray)
FULL → cap-full (red bg, red text)
≥80% → cap-almost (orange bg, orange text)
<80% → cap-open (green bg, green text)
```
**Styles:**
- Card: white background, 20rpx border-radius, shadow
- Time text: 36rpx, bold, dark
- Capacity badge: 22rpx, inline-flex, colored backgrounds
- Buttons: rounded pills (68rpx height, 34rpx border-radius)
- Cancel text: underlined, red (#ef4444)
- Booked bar: 6rpx tan bar on left edge
---
### 4⃣ **components/DateSelector.vue** (Date Picker)
**Props:**
```typescript
interface Props {
modelValue: string // YYYY-MM-DD
}
```
**Emits:**
- `update:modelValue` - v-model update
- `select` - Custom event on selection
**Data:**
```typescript
dateRange: computed(() => getDateRange(DATE_SELECTOR_DAYS))
// DATE_SELECTOR_DAYS = 7
// Returns array of { date, weekday, isToday }
```
**Template:**
```vue
<scroll-view scroll-x>
<view class="track">
<view v-for="item in dateRange" class="date-item"
:class="{ active: item.date === modelValue, today: item.isToday }">
<text class="weekday">{{ item.isToday ? '今天' : item.weekday }}</text>
<text class="day">{{ getDayNumber(item.date) }}</text>
<text class="month">{{ getMonthNumber(item.date) }}</text>
</view>
</view>
</scroll-view>
```
**Date Display Format:**
- Weekday: "周一", "周二", or "今天"
- Day: Large bold number (e.g., "5")
- Month: Small number (e.g., "4月")
**Styles:**
- Active state: tan background (#c9a87c), white text
- Today highlight: tan-colored weekday text (even if not active)
- Horizontal scroll, no scrollbar
---
### 5⃣ **components/TimePeriodFilter.vue** (Period Filter)
**Props:**
```typescript
type PeriodKey = keyof typeof TIME_PERIODS | null
interface Props {
modelValue: PeriodKey
}
```
**Emits:**
- `update:modelValue` - v-model update
- `change` - Custom event
**Constants:**
```typescript
const TIME_PERIODS = {
MORNING: { label: '上午', start: '06:00', end: '12:00' },
AFTERNOON: { label: '下午', start: '12:00', end: '18:00' },
EVENING: { label: '晚上', start: '18:00', end: '22:00' },
}
```
**Tabs Generated:**
```typescript
[
{ key: null, label: '全部' },
{ key: 'MORNING', label: '上午' },
{ key: 'AFTERNOON', label: '下午' },
{ key: 'EVENING', label: '晚上' },
]
```
**Template:**
```vue
<view v-for="tab in tabs" :class="{ active: modelValue === tab.key }">
{{ tab.label }}
</view>
```
**Active State:**
- Text color: tan (#c9a87c), weight: 600
- Bottom border: 4rpx tan underline (CSS ::after)
---
### 6⃣ **components/BookingConfirmPopup.vue** (Confirmation Modal)
**Props:**
```typescript
visible: boolean
slot: TimeSlotWithBookingStatus | null
memberships: MembershipWithCardType[]
```
**Emits:**
- `confirm` - { timeSlotId, membershipId }
- `cancel` - Popup closes
- `update:visible` - Manual visibility control
**Template Sections:**
**1. Overlay Mask:**
```vue
<view v-if="visible" class="popup-mask" @tap="handleMaskTap">
<!-- Clicking mask closes popup -->
</view>
```
**2. Header:**
```vue
<text class="popup-title">确认预约</text>
<view class="close-btn"></view>
```
**3. Info Section (read-only display):**
```
日期: 2026-04-05
时间: 09:00 - 10:00
剩余: 1 个名额
```
**4. Membership Card Selection:**
**Case A: 1 membership**
```vue
<view class="card-item selected">
💳
{{ membership.cardType.name }}
剩余 {{ remainingTimes }}
</view>
```
(Auto-selected, pre-filled)
**Case B: Multiple memberships**
```vue
<view v-for="m in memberships" class="card-item"
:class="{ selected: selectedMembershipId === m.id }">
<!-- User taps to select -->
</view>
```
**5. Deduction Tip:**
```vue
<view v-if="selectedMembership" class="deduction-tip">
确认后将从{{ selectedMembership.cardType.name }}扣除 1 次课时
</view>
```
**6. Action Buttons:**
```
[取消] [确认预约]
(Outline) (Tan solid)
(Disabled if no membership selected)
```
**Auto-selection Logic:**
```typescript
watch([() => props.visible, () => props.memberships],
([visible, memberships]) => {
if (visible && memberships.length > 0) {
selectedMembershipId.value = memberships[0].id
}
},
{ immediate: true }
)
```
**Confirm Handler:**
```typescript
function handleConfirm() {
emit('confirm', {
timeSlotId: props.slot.id,
membershipId: selectedMembershipId.value,
})
}
```
**Styles:**
- Modal: Fixed positioning, rgba(0,0,0,0.45) dark overlay
- Panel: White background, rounded top corners, 32rpx padding
- Card items: 24rpx padding, border, transition on select
- Buttons: 88rpx height, rounded pills (44rpx)
- Cancel: Outline style, gray text
- Confirm: Solid tan background, white text
---
### 7⃣ **stores/user.ts** (User State)
**Key State:**
```typescript
user: ref<UserProfileResponse | null>(null)
memberships: ref<readonly MembershipWithCardType[]>([])
token: ref<string>(uni.getStorageSync('token'))
```
**Key Computed:**
```typescript
loggedIn: computed(() => !!token.value)
activeMemberships: computed(() =>
memberships.value.filter(m => m.status === MembershipStatus.ACTIVE)
)
hasValidMembership: computed(() => activeMemberships.value.length > 0)
```
**Key Actions:**
```typescript
async function login()
async function fetchMemberships()
// GET /membership/my
async function logout()
```
---
### 8⃣ **utils/request.ts** (API Client)
**Base URL Logic:**
```typescript
const BASE_URL = (() => {
const { miniProgram } = uni.getAccountInfoSync()
if (miniProgram.envVersion !== 'develop') {
return 'https://focus.richarjiang.com/api'
}
return 'http://localhost:3000/api'
})()
```
**Main request() function:**
```typescript
function request<T>(options: RequestOptions): Promise<T> {
// 1. Get token from localStorage
const token = uni.getStorageSync('token')
// 2. Call uni.request with:
// - Authorization header (Bearer token)
// - Content-Type: application/json
// 3. Response handling:
// - 401 → Clear token, show "please login", reject
// - ≥400 → Extract error from response.message, reject
// - <400 & success: true → Resolve with data
// - <400 & success: false → Reject with message
// 4. Network fail → Reject with errMsg
}
export function get<T>(url, data?): Promise<T>
export function post<T>(url, data?): Promise<T>
export function put<T>(url, data?): Promise<T>
```
**⚠️ GET Request Issue:**
```typescript
// In get(), data becomes the request body
// But uni.request with GET should NOT have a body
// Query params should be in the URL string
// This might cause issues on some platforms!
```
---
### 9⃣ **utils/format.ts** (Date Utilities)
```typescript
formatDate(date): string
// Returns YYYY-MM-DD
getWeekdayLabel(date): string
// Returns "周一", "周二", ..., "周日"
isToday(date): boolean
// Compares year/month/day
getDateRange(days: number): ReadonlyArray
// Returns array of:
// {
// date: YYYY-MM-DD,
// weekday: "周一" | "今天" (if i===0),
// isToday: boolean
// }
// Uses i * 86400000ms for date increment
```
---
## 🔍 Data Types Overview
### TimeSlotWithBookingStatus (Extended from TimeSlot)
```typescript
interface TimeSlotWithBookingStatus extends TimeSlot {
readonly isBookedByMe: boolean // Has user already booked?
readonly myBookingId: string | null // ID needed to cancel
}
interface TimeSlot {
readonly id: string // UUID
readonly date: string // YYYY-MM-DD
readonly startTime: string // HH:MM
readonly endTime: string // HH:MM
readonly capacity: number // Max people
readonly bookedCount: number // Already booked
readonly status: TimeSlotStatus // OPEN|FULL|CLOSED
readonly source: TimeSlotSource // TEMPLATE|MANUAL
readonly templateId: string | null
}
```
### MembershipWithCardType
```typescript
interface MembershipWithCardType {
readonly id: string
readonly cardType: CardType
readonly status: MembershipStatus // ACTIVE|EXPIRED|USED_UP
readonly remainingTimes: number | null
readonly expireDate: string // YYYY-MM-DD
}
```
### CreateBookingDto
```typescript
interface CreateBookingDto {
readonly timeSlotId: string
readonly membershipId: string
}
```
---
## 🎨 Color Scheme
| Element | Color | Hex | Usage |
|---------|-------|-----|-------|
| Primary (Accent) | Tan/Brown | #c9a87c | Buttons, active tabs, highlights |
| Background | Light Beige | #f5f3f0 | Page background |
| Text Primary | Dark Gray | #1a1a1a | Main headings |
| Text Secondary | Medium Gray | #666/#999 | Labels, descriptions |
| Text Tertiary | Light Gray | #bbb | Disabled, hints |
| Success | Green | #4caf50 | Open slots (capacity label) |
| Warning | Orange | #f59e0b | Almost full (capacity label) |
| Error | Red | #ef4444 | Full/closed, cancel button |
| Borders | Very Light Gray | #f0f0f0/#f0ece8 | Dividers, borders |
---
## ⚠️ Potential Issues & Problems
### 1. **GET Request Body Issue**
**File:** `utils/request.ts` in `get()` function
```typescript
export function get<T>(url: string, data?: Record<string, unknown>): Promise<T> {
return request<T>({ url, method: 'GET', data }) // ← data as body!
}
```
**Problem:** GET requests shouldn't have a body. Query params should be in the URL.
**Impact:** `/time-slot/available?date=2026-04-05` might not work on all platforms.
### 2. **Empty Slots Array on Error**
**File:** `stores/booking.ts`, `fetchSlots()`
```typescript
catch (err) {
console.error('Fetch slots failed:', err)
slots.value = [] // ← Clears state on error!
}
```
**Problem:** Network error → page shows "empty state" instead of error message.
**Impact:** Users can't tell if there's an error or truly no slots available.
### 3. **No Error Handling in Main Page**
**File:** `pages/booking/index.vue`, `loadSlots()`
```typescript
async function loadSlots(date: string) {
await bookingStore.fetchSlots(date)
// ← No error handling, no user feedback
}
```
**Problem:** If fetchSlots() fails, user sees empty page with no explanation.
### 4. **Manual Date Calculation**
**File:** `utils/format.ts`, `getDateRange()`
```typescript
const d = new Date(now.getTime() + i * 86400000)
```
**Problem:** Doesn't account for DST transitions. Using `Date.setDate()` would be safer.
### 5. **No Loading State for Slots**
**File:** `pages/booking/index.vue`
```typescript
<view v-if="bookingStore.loadingSlots && !refreshing" class="loading-wrap">
```
**Problem:** Skeleton appears only on initial load, not when changing dates or refreshing.
**Impact:** Date changes appear instant (good UX but confusing if slow network).
### 6. **Hardcoded Membership Message**
**File:** `components/BookingConfirmPopup.vue`
```typescript
{{ selectedMembership.cardType.name }} 1
// ← Always says "1 次" even if card might deduct different amounts
```
**Problem:** Doesn't show actual deduction amount if dynamic.
---
## 📊 Event Flow Sequence
```
1. PAGE LOAD (onMounted)
├─ Check: userStore.loggedIn?
├─ If yes & no memberships: fetchMemberships()
└─ loadSlots(today)
└─ GET /time-slot/available?date=today
└─ bookingStore.slots = [...]
└─ render SlotCard components
2. USER TAPS DATE
├─ selectedDate.value = newDate
└─ onDateSelect(newDate)
└─ loadSlots(newDate)
└─ fetchSlots()
3. USER FILTERS PERIOD
├─ selectedPeriod.value = MORNING|AFTERNOON|EVENING|null
└─ filteredSlots computed updates
└─ SlotCards re-render (no new API call)
4. USER PULLS TO REFRESH
├─ onRefresh()
└─ loadSlots(selectedDate.value)
5. USER TAPS "可预约" BUTTON
├─ onBookTap(slot)
├─ Check login (if not → login modal)
├─ Check membership (if not → purchase modal)
└─ Show BookingConfirmPopup
└─ Pre-select first membership
6. USER CONFIRMS BOOKING
├─ onConfirmBooking({timeSlotId, membershipId})
├─ POST /booking
│ └─ bookingStore.createBooking()
├─ Show success toast
└─ loadSlots(selectedDate.value) // Refresh
└─ Updated slot.isBookedByMe = true
7. USER TAPS "取消" BUTTON
├─ onCancelTap(slot)
├─ Confirm modal
├─ PUT /booking/:id/cancel
│ └─ bookingStore.cancelBooking()
├─ Show success toast
└─ loadSlots(selectedDate.value) // Refresh
└─ Updated slot.isBookedByMe = false
```
---
## 🧪 Testing Scenarios
### ✅ Happy Path
- [ ] Load page → today's slots display
- [ ] Tap date → slots for that date display
- [ ] Filter by period → slots filtered correctly
- [ ] Tap "可预约" → popup shows with correct time/date
- [ ] Select membership → deduction message updates
- [ ] Confirm → booking created, slot shows "已预约"
- [ ] Pull to refresh → slots reload
- [ ] Tap "取消" → booking cancelled, slot back to "可预约"
### ⚠️ Edge Cases
- [ ] No slots for date → empty state appears
- [ ] User not logged in → login modal shows
- [ ] No valid membership → purchase modal shows
- [ ] Network error → ??? (currently shows empty)
- [ ] Slot changes to FULL → button becomes disabled
- [ ] Slot changes to CLOSED → button becomes disabled
---
## 🔧 Integration Points
**From Backend:**
1. ✅ GET `/time-slot/available?date=...` → Returns slots
2. ✅ POST `/booking` → Create booking
3. ✅ PUT `/booking/:id/cancel` → Cancel booking
4. ✅ GET `/membership/my` → List memberships
5. ✅ Auth via Bearer token
**From Frontend:**
1. ✅ LocalStorage for token persistence
2. ✅ uni.showModal, uni.showToast for UI feedback
3. ✅ uni.getSystemInfoSync() for responsive sizing
4. ✅ uni.navigateTo() for page navigation
---
## 📱 Responsive Layout
**Design Breakpoint:**
- Base: 750rpx (WeChat standard width unit)
- Window height: dynamic via uni.getSystemInfoSync().windowHeight
**Scroll Area Height Calculation:**
```typescript
scrollHeight = windowHeight - headerHeight(220rpx) - tabbarHeight(100rpx)
= windowHeight - (220 * (windowWidth / 750)) - (100 * (windowWidth / 750))
```
**Sticky Header:**
- Position: sticky (CSS)
- Top: 0
- Z-index: 100
- Contains: DateSelector + TimePeriodFilter
---
## 🎯 Summary
The booking system is well-architected with:
- ✅ Clear separation of concerns (component, store, utils)
- ✅ Proper type safety with TypeScript
- ✅ Responsive date/time selection
- ✅ Membership-based booking validation
- ✅ Optimistic loading states
- ✅ Accessible UI patterns
But needs:
- ⚠️ Better error handling
- ⚠️ Fix GET request implementation
- ⚠️ Loading state during date/period changes
- ⚠️ Network error user feedback