Files
mp-pilates/docs/TIME_SLOT_DIAGRAMS.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

25 KiB

Time-Slot & Scheduling System - Architecture Diagrams

1. Data Model Relationships

┌─────────────────────────────────────────────────────────────────┐
│                        WEEK TEMPLATE                             │
│                                                                   │
│  dayOfWeek (1-7, ISO standard)                                   │
│  startTime, endTime (e.g., "09:00", "10:00")                    │
│  capacity (default 1)                                            │
│  isActive (can disable template)                                 │
│                                                                   │
│              ↓ (auto-generates)                                  │
└─────────────────────────────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                        TIME SLOT                                 │
│                                                                   │
│  date (calendar date, midnight UTC)                              │
│  startTime, endTime (from template)                              │
│  capacity (from template)                                        │
│  bookedCount (# of current bookings)                             │
│  status (OPEN | FULL | CLOSED)                                 │
│  source (TEMPLATE | MANUAL)                                     │
│  templateId (reference to WeekTemplate)                          │
│                                                                   │
│     ↓ (has many)         ↓ (belongs to)                          │
│     └─────────────────────┘                                      │
└─────────────────────────────────────────────────────────────────┘
         ↑                                    ↑
         │                                    │
         └────────────┬─────────────────────────
                      │
                      │ 1:1 booking
                      │
                      ↓
┌─────────────────────────────────────────────────────────────────┐
│                        BOOKING                                   │
│                                                                   │
│  userId (FK to User)                                             │
│  timeSlotId (FK to TimeSlot)                                     │
│  membershipId (FK to Membership)                                 │
│  status (CONFIRMED | CANCELLED | COMPLETED | NO_SHOW)          │
│  cancelledAt (timestamp when cancelled)                          │
│                                                                   │
│  Constraints:                                                    │
│  - Unique [userId, timeSlotId]  (one booking per user per slot) │
│  - ONE booking per TimeSlot per user                             │
└─────────────────────────────────────────────────────────────────┘
         ↑                         ↑
         │                         │
         └─────────────┬───────────┘
                       │
                   belongs to
                       │
                       ↓
┌─────────────────────────────────────────────────────────────────┐
│                     MEMBERSHIP                                   │
│                                                                   │
│  userId (FK to User)                                             │
│  cardTypeId (FK to CardType)                                     │
│  remainingTimes (for TIMES/TRIAL card types)                    │
│  expireDate (for DURATION card types)                           │
│  status (ACTIVE | EXPIRED | USED_UP)                            │
└─────────────────────────────────────────────────────────────────┘

2. Daily Scheduler Timeline

00:00 ─────────────────────────────────────────────────
  │
  ├─ [Midnight] - Time passes
  │
  ├─ System running in background
  │
  ├─ ... (various other operations)
  │
02:00 ─────────────────────────────────────────────────
  │
  ├─► 🟢 SLOT GENERATION
  │   SlotGeneratorService.generateSlots(14)
  │   
  │   ├─ Query WeekTemplate (all isActive=true)
  │   ├─ For each day in [tomorrow, tomorrow+14):
  │   │  ├─ Get ISO weekday
  │   │  ├─ Find matching templates
  │   │  └─ Create TimeSlot entries
  │   ├─ Batch insert with skipDuplicates: true
  │   └─ Log: "Generated X new time slots"
  │
02:30 ─────────────────────────────────────────────────
  │
  ├─► 🟡 SLOT CLEANUP
  │   SlotGeneratorService.cleanupExpiredSlots()
  │   
  │   ├─ Find all OPEN slots with date < TODAY
  │   ├─ Mark as CLOSED
  │   └─ Log: "Closed X expired time slots"
  │
03:00 ─────────────────────────────────────────────────
  │
  ├─► 🟠 MEMBERSHIP CHECK
  │   SlotGeneratorService.checkExpiredMemberships()
  │   
  │   ├─ Update ACTIVE memberships with expireDate < NOW
  │   │  └─ Set status = EXPIRED
  │   ├─ Update ACTIVE memberships with remainingTimes = 0
  │   │  └─ Set status = USED_UP
  │   └─ Log: "Expired X by date, Y by sessions"
  │
  ├─ ... (users awake, making bookings)
  │
22:00 ─────────────────────────────────────────────────
  │
  ├─► 🔴 BOOKING COMPLETION
  │   SlotGeneratorService.completeBookings()
  │   
  │   ├─ Find CONFIRMED bookings with timeSlot.date < TODAY
  │   ├─ Mark as COMPLETED
  │   └─ Log: "Completed X past bookings"
  │
  └─ (Day ends, repeat tomorrow)

3. Booking Lifecycle

┌─────────────────────────────────────────────────────────┐
│                 BOOKING CREATION                         │
│                 (POST /booking)                          │
└─────────────────────────────────────────────────────────┘
  │
  ├─ Input: { timeSlotId, membershipId }
  │
  ├─ TRANSACTION START ──────────────────
  │   │
  │   ├─► Fetch TimeSlot
  │   │   └─ Check: status = OPEN? ✓
  │   │
  │   ├─► Check Duplicate
  │   │   └─ Query: SELECT * FROM bookings WHERE userId=? AND timeSlotId=?
  │   │   └─ Must not exist
  │   │
  │   ├─► Fetch Membership
  │   │   └─ Check: belongs to user? ✓
  │   │   └─ Check: status = ACTIVE? ✓
  │   │   └─ Check: has capacity?
  │   │       └─ IF TIMES/TRIAL: remainingTimes > 0? ✓
  │   │       └─ IF DURATION: expireDate > NOW? ✓
  │   │
  │   ├─► CREATE Booking(CONFIRMED)
  │   │   └─ INSERT: { userId, timeSlotId, membershipId, status: CONFIRMED }
  │   │
  │   ├─► UPDATE TimeSlot
  │   │   ├─ bookedCount = bookedCount + 1
  │   │   ├─ IF bookedCount >= capacity:
  │   │   │  └─ status = FULL
  │   │   └─ ELSE:
  │   │      └─ status = OPEN (unchanged)
  │   │
  │   ├─► UPDATE Membership (if TIMES/TRIAL)
  │   │   ├─ remainingTimes = remainingTimes - 1
  │   │   ├─ IF remainingTimes <= 0:
  │   │   │  └─ status = USED_UP
  │   │   └─ ELSE:
  │   │      └─ status = ACTIVE (unchanged)
  │   │
  │   └─ TRANSACTION COMMIT ──────────────
  │
  └─► Return: BookingWithRelations (includes timeSlot, membership)


┌─────────────────────────────────────────────────────────┐
│              BOOKING CANCELLATION                        │
│              (PUT /booking/:id/cancel)                  │
└─────────────────────────────────────────────────────────┘
  │
  ├─ Fetch Booking + TimeSlot + Membership
  │
  ├─ Check: booking.status = CONFIRMED? ✓
  │
  ├─ Calculate Refund Eligibility
  │   │
  │   ├─ cancelHoursLimit = StudioConfig.cancelHoursLimit (default 2)
  │   ├─ slotStartMs = Date(timeSlot.date) + timeSlot.startTime
  │   ├─ deadlineMs = NOW + (cancelHoursLimit * 3600 * 1000)
  │   │
  │   ├─ IF slotStartMs >= deadlineMs:
  │   │  └─ withinLimit = TRUE ✓ (User gets refund)
  │   └─ ELSE:
  │      └─ withinLimit = FALSE (No refund)
  │
  ├─ TRANSACTION START ──────────────────
  │   │
  │   ├─► UPDATE Booking
  │   │   ├─ status = CANCELLED
  │   │   └─ cancelledAt = NOW
  │   │
  │   ├─► UPDATE TimeSlot
  │   │   ├─ bookedCount = MAX(0, bookedCount - 1)
  │   │   ├─ IF slot was FULL:
  │   │   │  └─ status = OPEN
  │   │   └─ ELSE:
  │   │      └─ status = (unchanged)
  │   │
  │   ├─► IF withinLimit = TRUE:
  │   │   └─ UPDATE Membership (if TIMES/TRIAL)
  │   │       ├─ remainingTimes = remainingTimes + 1
  │   │       ├─ IF was USED_UP:
  │   │       │  └─ status = ACTIVE
  │   │       └─ ELSE:
  │   │          └─ status = (unchanged)
  │   │
  │   └─ TRANSACTION COMMIT ──────────────
  │
  └─► Return: { booking, refunded: boolean }

4. Slot Generation from Template

Template Setup:
┌─────────────────────────────────────────────────────┐
│  PUT /admin/week-template                           │
│                                                     │
│  {                                                  │
│    "templates": [                                   │
│      {                                              │
│        "dayOfWeek": 1,      // Monday (ISO standard)│
│        "startTime": "09:00",                        │
│        "endTime": "10:00",                          │
│        "capacity": 1,       // Private lesson       │
│        "isActive": true                             │
│      },                                             │
│      {                                              │
│        "dayOfWeek": 5,      // Friday (ISO standard)│
│        "startTime": "18:00",                        │
│        "endTime": "19:00",                          │
│        "capacity": 1,                               │
│        "isActive": true                             │
│      }                                              │
│    ]                                                │
│  }                                                  │
└─────────────────────────────────────────────────────┘
              ↓
   Stored in database as WeekTemplates
              ↓
   Each day at 02:00 UTC, generateSlots(14) runs:


  Today: Monday, April 7, 2026
    │
    ├─ Tomorrow = Tuesday, April 8
    │
    ├─ For next 14 days:
    │
    │   Day 0: Tue (ISO 2) → no matching template → skip
    │   Day 1: Wed (ISO 3) → no matching template → skip
    │   Day 2: Thu (ISO 4) → no matching template → skip
    │   Day 3: Fri (ISO 5) → MATCH! template (18:00-19:00)
    │           └─ CREATE TimeSlot(date=Apr12, time=18:00-19:00, capacity=1)
    │
    │   Day 4: Sat (ISO 6) → no matching template → skip
    │   Day 5: Sun (ISO 7) → no matching template → skip
    │   Day 6: Mon (ISO 1) → MATCH! template (09:00-10:00)
    │           └─ CREATE TimeSlot(date=Apr14, time=09:00-10:00, capacity=1)
    │
    │   Day 7: Tue (ISO 2) → no matching template → skip
    │   ... (repeats pattern)
    │
    └─ All created with:
       ├─ status = OPEN
       ├─ bookedCount = 0
       ├─ source = TEMPLATE
       ├─ templateId = (reference to template)
       └─ skipDuplicates = true (safe to re-run)


Result:
  14 Friday 18:00-19:00 slots generated
  14 Monday 09:00-10:00 slots generated
  Total: 28 new slots

5. User Booking Flow (Frontend → Backend)

┌──────────────────────────────────────────────────────┐
│  MEMBER CLIENT                                       │
└──────────────────────────────────────────────────────┘
         │
         │ 1. Click "View Available Slots"
         │
         ├─► GET /time-slot/available?date=2026-04-10
         │
         │   Response: [{
         │     id: "slot-123",
         │     date: "2026-04-10",
         │     startTime: "09:00",
         │     endTime: "10:00",
         │     status: "OPEN",
         │     bookedCount: 0,
         │     capacity: 1,
         │     isBookedByMe: false,        ← User's booking status
         │     myBookingId: null
         │   }, ...]
         │
         ├─ Display available slots in UI
         │
         │ 2. User selects slot and membership
         │
         ├─► POST /booking
         │   Body: {
         │     "timeSlotId": "slot-123",
         │     "membershipId": "mem-456"
         │   }
         │
         │   Response: {
         │     id: "booking-789",
         │     userId: "user-001",
         │     timeSlotId: "slot-123",
         │     status: "CONFIRMED",
         │     createdAt: "2026-04-05T10:30:00Z",
         │     timeSlot: { ... },        ← Full slot details
         │     membership: { ... }       ← Full membership details
         │   }
         │
         ├─ Display confirmation
         │
         │ 3. [Later] User cancels booking
         │
         └─► PUT /booking/booking-789/cancel
             
             Response: {
               booking: { ... },
               refunded: true             ← Was refund issued?
             }
             
             Display: "Booking cancelled. You've been refunded."

6. State Transitions

TimeSlot Status

    ┌─────────────────────────────────┐
    │   AUTO-GENERATED               │
    │   by generateSlots()            │
    └─────────────┬───────────────────┘
                  │
                  ↓
         ┌─────────────────┐
         │      OPEN       │  ← Can accept bookings
         │ (bookedCount <  │     bookedCount starts at 0
         │  capacity)      │
         └────────┬────────┘
                  │
       ┌──────────┼──────────┐
       │          │          │
       │          │          │
    [booking      │       [cleanup
     creates]     │        or manual
                  │        close]
       │          │          │
       ↓          ↓          ↓
    FULL       CLOSED
    (bookedCount >= capacity)
       │
       │ [booking cancelled]
       ↓
    OPEN (back to)


    Once slot date passes:
    ├─ OPEN → CLOSED (by cleanup job at 02:30 UTC)
    ├─ FULL → CLOSED (when cleanup runs)
    └─ CANCELLED bookings don't affect slot status

Booking Status

    ┌──────────────────┐
    │    CONFIRMED     │  ← Default when created
    │                  │     User has active reservation
    └────────┬─────────┘
             │
       ┌─────┼─────┐
       │     │     │
    [user   │   [auto-mark
    cancels] │    when date
       │    │    passes]
       │    │     │
       ↓    ↓     ↓
    CANCELLED  COMPLETED
    (free     (slot time
    cancellation has passed)
     until deadline)


    CANCELLED bookings stay in history
    COMPLETED bookings show in past bookings
    CONFIRMED bookings show in upcoming bookings

Membership Status

    ┌─────────────────┐
    │     ACTIVE      │  ← Can book classes
    │                 │     Has remaining capacity
    └────────┬────────┘
             │
       ┌─────┼─────┐
       │     │     │
    [booking   │  [auto-check
    depletes   │   by scheduler
    sessions]  │   at 03:00 UTC]
       │       │     │
       ↓       ↓     ↓
    USED_UP  EXPIRED
    (for     (for
     TIMES)   DURATION)


    USED_UP: remainingTimes = 0 (for TIMES/TRIAL only)
    EXPIRED: expireDate < NOW (for DURATION) OR date-based expiry
    
    All non-ACTIVE statuses prevent new bookings

7. Timezone & Date Handling

User Timezone: Local (browser/app)
API Timezone: UTC (backend)
Database: UTC

┌──────────────────────────────────────────────┐
│  User in Shanghai (UTC+8)                    │
│  Local time: 2026-04-10 15:00:00 CST         │
│  UTC time: 2026-04-10 07:00:00 UTC           │
└──────────────────────────────────────────────┘
           │
           ├─ Query: GET /time-slot/available?date=2026-04-10
           │         (User sends local date, frontend converts to ISO)
           │
           ├─ Backend receives:
           │  ├─ Parse "2026-04-10"
           │  ├─ Build start of day: 2026-04-10T00:00:00 UTC
           │  ├─ Build end of day: 2026-04-10T23:59:59.999 UTC
           │  ├─ Query TimeSlots WHERE date BETWEEN [00:00, 23:59]
           │
           └─ Return slots for that calendar day in UTC


┌──────────────────────────────────────────────┐
│  TimeSlot Storage (Database)                 │
│                                              │
│  date: 2026-04-10 (DATE type, midnight UTC) │
│  startTime: "09:00" (string, no timezone)   │
│  endTime: "10:00" (string, no timezone)     │
│                                              │
│  When combined:                              │
│  Slot datetime = 2026-04-10T09:00:00 UTC    │
└──────────────────────────────────────────────┘
           │
           ├─ For Shanghai user (UTC+8):
           │  └─ 09:00 UTC = 17:00 CST (5 PM)
           │
           └─ For New York user (UTC-4):
              └─ 09:00 UTC = 05:00 EDT (5 AM)


Scheduler (UTC times):
┌─────────────────────────────────────────────┐
│  02:00 UTC = Generate slots                 │
│  02:30 UTC = Cleanup                        │
│  03:00 UTC = Check memberships              │
│  22:00 UTC = Complete bookings              │
│                                              │
│  When scheduler checks "is date < today?":  │
│  ├─ Create midnight UTC boundary            │
│  ├─ Compare slot.date < today's midnight    │
│  └─ Mark as CLOSED/COMPLETED if older       │
└─────────────────────────────────────────────┘

8. Error Handling Tree

POST /booking
│
├─ TimeSlot not found
│  └─ Return: NotFoundException
│
├─ TimeSlot.status ≠ OPEN
│  └─ Return: BadRequestException("TimeSlot is not available")
│
├─ Duplicate booking exists
│  └─ Return: ConflictException("Already booked this slot")
│
├─ Membership not found
│  └─ Return: NotFoundException
│
├─ Membership.userId ≠ current user
│  └─ Return: ForbiddenException("Not your membership")
│
├─ Membership.status ≠ ACTIVE
│  └─ Return: BadRequestException("Membership inactive")
│
├─ Card type is TIMES/TRIAL:
│  │
│  └─ remainingTimes ≤ 0
│     └─ Return: BadRequestException("No remaining times")
│
└─ Card type is DURATION:
   │
   └─ expireDate < NOW
      └─ Return: BadRequestException("Membership expired")


PUT /booking/:id/cancel
│
├─ Booking not found
│  └─ Return: NotFoundException
│
├─ Booking.userId ≠ current user
│  └─ Return: ForbiddenException("Not your booking")
│
├─ Booking.status ≠ CONFIRMED
│  └─ Return: BadRequestException("Can't cancel this status")
│
└─ ✓ Cancel successful
   ├─ Check refund eligibility
   ├─ Update booking status
   ├─ Update timeSlot bookedCount
   └─ Conditionally refund membership

9. Integration Points

┌─────────────────────────────────────────────────────┐
│              APP MODULE                             │
│  (packages/server/src/app.module.ts)               │
└─────────────────────────────────────────────────────┘
       │
       ├─ imports: [
       │   AuthModule,
       │   TimeSlotModule,           ← Time-Slot logic
       │   SchedulerModule,          ← Auto jobs (cron)
       │   BookingModule,            ← Booking logic
       │   MembershipModule,         ← Membership checks
       │   StudioModule,             ← Config (cancelHoursLimit)
       │   ...
       │ ]
       │
       └─ Controllers route to:
          │
          ├─ TimeSlotController (public slots viewing)
          ├─ AdminTimeSlotController (templates, admin actions)
          ├─ BookingController (create, cancel bookings)
          └─ ... (other endpoints)


SchedulerModule dependencies:
├─ ScheduleModule.forRoot()  ← Enable @Cron decorators
└─ TimeSlotModule           ← Access to SlotGeneratorService


BookingModule dependencies:
├─ MembershipModule         ← Check membership status
└─ StudioModule             ← Read cancelHoursLimit config


Services call chain:
├─ Controller
│  ├─ TimeSlotService
│  │  └─ PrismaService
│  └─ BookingService
│     ├─ PrismaService
│     ├─ MembershipService
│     └─ StudioService