rules/timezone-rules.md

Timezone Rules (BẮT BUỘC)

Vi phạm timezone rules = data corruption. Không có ngoại lệ.


1. Nguyên tắc cốt lõi

┌─────────────────────────────────────────────────────────────┐
│  Mọi thời gian user nhìn thấy  = giờ SALON (Europe/Oslo)   │
│  Mọi thời gian lưu DB          = UTC                        │
│  Timezone của user (browser)    = KHÔNG BAO GIỜ được dùng   │
└─────────────────────────────────────────────────────────────┘

2. Data Types & Storage

Data Kiểu Ví dụ Timezone
Business hours String "HH:mm" "09:00" Salon local — KHÔNG convert
Resource schedule String "HH:mm" "09:00" Salon local — KHÔNG convert
Schedule override time String "HH:mm" nullable "10:00" / null Salon local
Schedule override date @db.Date 2026-04-14 Calendar date (no tz)
Booking startTime/endTime DateTime 2026-04-14T07:00:00Z UTC
Tenant timezone String IANA "Europe/Oslo" Config — source of truth

3. Conversion Rules

Khi LƯU booking (frontend → API)

// Frontend: user chọn 09:00 ngày 14/04 → convert salon local → UTC
import { toUTC } from '@/lib/timezone';
const utcISO = toUTC('2026-04-14', '09:00', 'Europe/Oslo');
// → "2026-04-14T07:00:00.000Z" (Oslo UTC+2 summer)

Khi HIỂN THỊ booking (API → frontend)

// DB trả về UTC → convert sang salon local để hiển thị
import { formatTimeInZone } from '@/lib/timezone';
formatTimeInZone('2026-04-14T07:00:00Z', 'Europe/Oslo');
// → "09:00"

Khi tính AVAILABILITY (API)

// Schedule "09:00" là salon local → convert sang UTC để so sánh với bookings
import { localToUTC } from '../../common/timezone.util';
localToUTC('2026-04-14', '09:00', 'Europe/Oslo');
// → Date(2026-04-14T07:00:00Z)

4. KHÔNG BAO GIỜ làm

// ❌ KHÔNG: Coi schedule time là UTC
new Date(`${date}T${time}:00Z`)  // Z = UTC! Schedule "09:00" ≠ 09:00 UTC

// ❌ KHÔNG: Dùng toISOString() để lấy date string
d.toISOString().split('T')[0]   // Lệch ngày nếu local ≠ UTC

// ❌ KHÔNG: Dùng new Date(dateStr) không có time component
new Date('2026-04-14')           // JS parse as UTC midnight → getDay() có thể lệch

// ❌ KHÔNG: Dùng getHours(), getDay() etc. trên UTC dates
booking.startTime.getHours()     // Server timezone, không phải salon timezone

// ❌ KHÔNG: Dùng browser timezone cho bất cứ logic nào
new Date().getDay()              // Browser timezone, không phải salon timezone
// (Trừ khi chỉ để hiển thị week navigation UI — không liên quan booking logic)

5. LUÔN LUÔN làm

// ✅ ĐÚNG: Date string từ Date object → dùng local methods
function toDateString(d: Date): string {
  return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}`;
}

// ✅ ĐÚNG: DayOfWeek từ date string → dùng noon UTC (tránh lệch ngày)
function getDayOfWeek(date: string): number {
  return new Date(`${date}T12:00:00Z`).getUTCDay();
}

// ✅ ĐÚNG: Hiển thị thời gian booking → dùng salon timezone
formatTimeInZone(booking.startTime, tenant.settings.timezone);

// ✅ ĐÚNG: So sánh business hours → convert UTC sang salon local
const parts = getPartsInZone(utcDate, salonTimezone);
// → { dayOfWeek, hour, minute } in salon local time

6. Utility Files

File Vị trí Mục đích
timezone.ts booking-web/src/lib/ Frontend: toUTC, toSalonTime, formatTimeInZone, formatDateTimeInZone, getPartsInZone, createDateTimeFormatterInZone
timezone.util.ts booking-api/src/common/ API: localToUTC, getDayOfWeek, getPartsInZone

7. Flow Diagram

sequenceDiagram
    participant Owner
    participant DB
    participant Customer
    participant API
    participant FE

    Note over Owner,DB: Salon setup
    Owner->>DB: schedule "09:00-17:00"<br/>(plain string, salon local — KHÔNG convert)
    Note over DB: Hiển thị: as-is, no conversion

    Note over Customer,FE: Customer xem available slots (date: 2026-04-14)
    Customer->>API: GET /availability?date=2026-04-14
    API->>API: localToUTC("2026-04-14","09:00","Europe/Oslo") = 07:00Z
    API->>API: localToUTC("2026-04-14","17:00","Europe/Oslo") = 15:00Z
    API->>API: Generate slots: 07:00Z, 07:15Z, ..., 14:45Z
    API-->>FE: slots as UTC ISO strings
    FE->>FE: formatTimeInZone("07:00Z","Europe/Oslo") = "09:00"
    FE-->>Customer: 09:00, 09:15, 09:30 ... 16:45 (salon time)

    Note over Customer,DB: Customer book slot "09:00" salon time
    Customer->>FE: pick 09:00 on 2026-04-14
    FE->>FE: toUTC("2026-04-14","09:00","Europe/Oslo") = "2026-04-14T07:00:00Z"
    FE->>API: POST /bookings { startTime: "...07:00:00Z" }
    API->>DB: INSERT booking.startTime = 2026-04-14T07:00:00Z (UTC)
    Note over DB: Calendar render: formatTimeInZone("07:00Z","Europe/Oslo") = "09:00" ✓

8. Display snapshot — bảo vệ historic bookings

BookingService.create ghi booking.metadata.displaySnapshot = { timezone, currency, locale } (frozen snapshot từ tenant settings tại lúc tạo). FE render booking time / money từ snapshot này, KHÔNG phải tenant setting hiện tại — tránh tenant đổi timezone về sau retroactively shift historical bookings.

Customer portal: GET /customer/me/bookings flatten booking.tenantTimezone ở DTO boundary để FE render formatDateTimeInZone(startTime, tenantTimezone) không cần re-fetch tenant.


9. Tham khảo