diff --git a/AGENTS.md b/AGENTS.md index 1ab24e8..e585365 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -137,6 +137,14 @@ Runtime app secrets are stored on the server in: Never commit real secrets. +Booking and appointment availability are calculated in the salon timezone: + +```text +APP_TIMEZONE=America/Chicago +``` + +Keep this value in `deploy/lightsail/api.env` unless the salon operating timezone changes. + ## Production Environment Notes Current no-domain/IP setup: @@ -283,4 +291,3 @@ High-value items still worth adding: - Non-destructive production seed runbook in docs. - Cleaner API test logs by mocking expected `console.error` output. - Database migrations instead of relying on Sequelize sync for schema changes. - diff --git a/apps/api/src/controllers/appointment.controller.ts b/apps/api/src/controllers/appointment.controller.ts index 0050083..7806e07 100644 --- a/apps/api/src/controllers/appointment.controller.ts +++ b/apps/api/src/controllers/appointment.controller.ts @@ -8,16 +8,26 @@ import { import { AppointmentStatus } from "../models/appointment.model"; import { sendMail } from "../services/email.service"; import { getBookingConfirmationTemplate } from "../utils/email-template.util"; +import { + formatLocalDate, + formatLocalTime, + parseLocalDateTimeToDate, +} from "../utils/timezone.util"; const appointmentController = { reserveAppointment: async (req: Request, res: Response, next: NextFunction) => { try { const { staffId, scheduledAt } = ReserveAppointmentSchema.parse(req.body); const customerId = (req as any).user?.sub; + const parsedScheduledAt = parseLocalDateTimeToDate(scheduledAt); + + if (!parsedScheduledAt) { + return res.badRequest("scheduledAt must be a valid local date and time"); + } const reservation = await appointmentService.reserveAppointment({ staffId, - scheduledAt: new Date(scheduledAt), + scheduledAt: parsedScheduledAt, customerId }); @@ -94,14 +104,8 @@ const appointmentController = { day: "2-digit", month: "2-digit", year: "numeric", - timeZone: "Asia/Bangkok", - }).format(scheduledAt), - time: new Intl.DateTimeFormat("en-GB", { - hour: "2-digit", - minute: "2-digit", - hour12: false, - timeZone: "Asia/Bangkok", - }).format(scheduledAt), + }).format(new Date(`${formatLocalDate(scheduledAt)}T00:00:00`)), + time: formatLocalTime(scheduledAt), isGuest: !customerId, services: services.map((service: any) => ({ name: service.name, diff --git a/apps/api/src/services/_tests_/appointment.service.test.ts b/apps/api/src/services/_tests_/appointment.service.test.ts index 956153a..961084e 100644 --- a/apps/api/src/services/_tests_/appointment.service.test.ts +++ b/apps/api/src/services/_tests_/appointment.service.test.ts @@ -292,7 +292,7 @@ describe("Appointment Service", () => { const result = await appointmentService.findConfirmedAppointmentsForCheckIn( "5551234567", - "2026-04-13" + "2026-04-12" ); expect(Appointment.findAll).toHaveBeenNthCalledWith(2, { diff --git a/apps/api/src/services/appointment.service.ts b/apps/api/src/services/appointment.service.ts index cb65b30..61df09c 100644 --- a/apps/api/src/services/appointment.service.ts +++ b/apps/api/src/services/appointment.service.ts @@ -6,6 +6,13 @@ import { Service } from "../models/service.model"; import { User } from "../models/user.model"; import staffRepo from "../repo/staff.repo"; import { HttpError } from "../utils/error.util"; +import { + formatLocalDate, + formatLocalTime, + formatLocalWeekday, + getLocalClock, + getLocalDayRange, +} from "../utils/timezone.util"; const TIME_SLOTS = [ "09:00", @@ -22,13 +29,6 @@ const TIME_SLOTS = [ ]; const DEFAULT_SERVICE_DURATION_MINUTES = 30; -const APP_TIMEZONE = "Asia/Bangkok"; -const APP_DATE_FORMATTER = new Intl.DateTimeFormat("sv-SE", { - year: "numeric", - month: "2-digit", - day: "2-digit", - timeZone: APP_TIMEZONE, -}); type AppointmentActor = { sub: string; @@ -97,7 +97,7 @@ function isSameLocalDate(value: Date | string, dateStr: string) { const date = value instanceof Date ? value : new Date(value); if (Number.isNaN(date.getTime())) return false; - return APP_DATE_FORMATTER.format(date) === dateStr; + return formatLocalDate(date) === dateStr; } async function completeElapsedCheckedInAppointments() { @@ -190,12 +190,11 @@ const appointmentService = { }, }); - const [year, month, day] = dateStr.split('-').map(Number); - if (!year || !month || !day) return []; - const startOfDay = new Date(year, month - 1, day, 0, 0, 0); - const endOfDay = new Date(year, month - 1, day, 23, 59, 59); + const dayRange = getLocalDayRange(dateStr); + if (!dayRange) return []; - const dayOfWeek = startOfDay.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase(); + const { start: startOfDay, end: endOfDay } = dayRange; + const dayOfWeek = formatLocalWeekday(startOfDay); let workingStaff = await staffRepo.getStaffByWorkDay(dayOfWeek); // Filter working staff if a specific staff member is requested @@ -217,30 +216,21 @@ const appointmentService = { }); const availableSlots: string[] = []; - const now = new Date(); - - const isToday = now.getFullYear() === year && - now.getMonth() === month - 1 && - now.getDate() === day; + const now = getLocalClock(); + const isToday = now.date === dateStr; for (const slot of TIME_SLOTS) { if (isToday) { const [slotH, slotM] = slot.split(':').map(Number); if (slotH === undefined || slotM === undefined) continue; - const currentH = now.getHours(); - const currentM = now.getMinutes(); - - if (slotH < currentH || (slotH === currentH && slotM <= currentM)) { + if (slotH < now.hour || (slotH === now.hour && slotM <= now.minute)) { continue; } } const busyCount = appointments.filter(app => { - const appTime = new Date(app.scheduledAt); - const appH = appTime.getHours().toString().padStart(2, '0'); - const appM = appTime.getMinutes().toString().padStart(2, '0'); - return `${appH}:${appM}` === slot; + return formatLocalTime(new Date(app.scheduledAt)) === slot; }).length; // If a specific staff is filtered, they are available only if busyCount is 0 for that slot @@ -283,7 +273,7 @@ const appointmentService = { // Handle 'anyone' logic if (staffId === 'anyone') { - const dayOfWeek = data.scheduledAt.toLocaleDateString('en-US', { weekday: 'short' }).toUpperCase(); + const dayOfWeek = formatLocalWeekday(data.scheduledAt); const workingStaff = await staffRepo.getStaffByWorkDay(dayOfWeek); if (workingStaff.length === 0) { diff --git a/apps/api/src/utils/_tests_/timezone.util.test.ts b/apps/api/src/utils/_tests_/timezone.util.test.ts new file mode 100644 index 0000000..fd051f6 --- /dev/null +++ b/apps/api/src/utils/_tests_/timezone.util.test.ts @@ -0,0 +1,25 @@ +import { + formatLocalDate, + formatLocalTime, + parseLocalDateTimeToDate, +} from "../timezone.util"; + +describe("timezone utilities", () => { + const originalTimeZone = process.env.APP_TIMEZONE; + + beforeEach(() => { + process.env.APP_TIMEZONE = "America/Chicago"; + }); + + afterEach(() => { + process.env.APP_TIMEZONE = originalTimeZone; + }); + + it("parses booking datetimes as salon-local times", () => { + const scheduledAt = parseLocalDateTimeToDate("2026-05-12T09:00"); + + expect(scheduledAt).toBeInstanceOf(Date); + expect(formatLocalDate(scheduledAt as Date)).toBe("2026-05-12"); + expect(formatLocalTime(scheduledAt as Date)).toBe("09:00"); + }); +}); diff --git a/apps/api/src/utils/timezone.util.ts b/apps/api/src/utils/timezone.util.ts new file mode 100644 index 0000000..261c95d --- /dev/null +++ b/apps/api/src/utils/timezone.util.ts @@ -0,0 +1,182 @@ +const DEFAULT_APP_TIMEZONE = "America/Chicago"; + +type DateParts = { + year: number; + month: number; + day: number; +}; + +type DateTimeParts = DateParts & { + hour: number; + minute: number; + second: number; +}; + +const getAppTimeZone = () => process.env.APP_TIMEZONE?.trim() || DEFAULT_APP_TIMEZONE; + +const dateFormatter = (timeZone = getAppTimeZone()) => + new Intl.DateTimeFormat("sv-SE", { + year: "numeric", + month: "2-digit", + day: "2-digit", + timeZone, + }); + +const timeFormatter = (timeZone = getAppTimeZone()) => + new Intl.DateTimeFormat("en-GB", { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + timeZone, + }); + +const weekdayFormatter = (timeZone = getAppTimeZone()) => + new Intl.DateTimeFormat("en-US", { + weekday: "short", + timeZone, + }); + +function getDateTimeParts(date: Date, timeZone = getAppTimeZone()): DateTimeParts { + const formatter = new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + timeZone, + }); + + const parts = formatter.formatToParts(date); + const value = (type: Intl.DateTimeFormatPartTypes) => + Number(parts.find((part) => part.type === type)?.value ?? 0); + + return { + year: value("year"), + month: value("month"), + day: value("day"), + hour: value("hour"), + minute: value("minute"), + second: value("second"), + }; +} + +function getTimeZoneOffsetMs(date: Date, timeZone = getAppTimeZone()) { + const parts = getDateTimeParts(date, timeZone); + const zonedAsUtc = Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + ); + + return zonedAsUtc - date.getTime(); +} + +export function parseLocalDate(value: string): DateParts | null { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return null; + + const [, yearValue, monthValue, dayValue] = match; + const year = Number(yearValue); + const month = Number(monthValue); + const day = Number(dayValue); + const utcDate = new Date(Date.UTC(year, month - 1, day)); + + if ( + utcDate.getUTCFullYear() !== year || + utcDate.getUTCMonth() !== month - 1 || + utcDate.getUTCDate() !== day + ) { + return null; + } + + return { year, month, day }; +} + +export function parseLocalDateTime(value: string): DateTimeParts | null { + const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?$/.exec(value); + if (!match) return null; + + const [, yearValue, monthValue, dayValue, hourValue, minuteValue, secondValue = "0"] = match; + const dateParts = parseLocalDate(`${yearValue}-${monthValue}-${dayValue}`); + if (!dateParts) return null; + + const hour = Number(hourValue); + const minute = Number(minuteValue); + const second = Number(secondValue); + + if (hour > 23 || minute > 59 || second > 59) return null; + + return { + ...dateParts, + hour, + minute, + second, + }; +} + +export function localDateTimeToDate(parts: DateTimeParts, timeZone = getAppTimeZone()) { + const utcGuess = new Date(Date.UTC( + parts.year, + parts.month - 1, + parts.day, + parts.hour, + parts.minute, + parts.second, + )); + const firstPass = new Date(utcGuess.getTime() - getTimeZoneOffsetMs(utcGuess, timeZone)); + + return new Date(firstPass.getTime() - getTimeZoneOffsetMs(firstPass, timeZone) + getTimeZoneOffsetMs(utcGuess, timeZone)); +} + +export function parseLocalDateTimeToDate(value: string, timeZone = getAppTimeZone()) { + const parts = parseLocalDateTime(value); + return parts ? localDateTimeToDate(parts, timeZone) : null; +} + +export function getLocalDayRange(value: string, timeZone = getAppTimeZone()) { + const parts = parseLocalDate(value); + if (!parts) return null; + + const start = localDateTimeToDate({ ...parts, hour: 0, minute: 0, second: 0 }, timeZone); + const nextDay = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + 1)); + const endParts = { + year: nextDay.getUTCFullYear(), + month: nextDay.getUTCMonth() + 1, + day: nextDay.getUTCDate(), + hour: 0, + minute: 0, + second: 0, + }; + const end = localDateTimeToDate(endParts, timeZone); + + return { start, end, parts }; +} + +export function formatLocalDate(date: Date, timeZone = getAppTimeZone()) { + return dateFormatter(timeZone).format(date); +} + +export function formatLocalTime(date: Date, timeZone = getAppTimeZone()) { + return timeFormatter(timeZone).format(date).slice(0, 5); +} + +export function formatLocalWeekday(date: Date, timeZone = getAppTimeZone()) { + return weekdayFormatter(timeZone).format(date).toUpperCase(); +} + +export function getLocalClock(date = new Date(), timeZone = getAppTimeZone()) { + const parts = getDateTimeParts(date, timeZone); + return { + date: `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`, + hour: parts.hour, + minute: parts.minute, + }; +} + +export { getAppTimeZone }; diff --git a/apps/web/src/components/services/ServiceCategory.tsx b/apps/web/src/components/services/ServiceCategory.tsx index 5d17d93..e275e84 100644 --- a/apps/web/src/components/services/ServiceCategory.tsx +++ b/apps/web/src/components/services/ServiceCategory.tsx @@ -28,7 +28,6 @@ const ServiceCategory = ({ title, items }: ServiceCategoryProps) => (

{item.name} - {!item.name.endsWith(':') && ':'}

{item.price} diff --git a/apps/web/src/pages/Services.tsx b/apps/web/src/pages/Services.tsx index 34943e3..64452ae 100644 --- a/apps/web/src/pages/Services.tsx +++ b/apps/web/src/pages/Services.tsx @@ -1,10 +1,8 @@ -import { useEffect, useState } from 'react' import nailEnhancementImg from '../assets/nail-enhancement.jpg' import BeverageSection from '../components/services/BeverageSection.tsx' import Catalog from '../components/services/Catalog.tsx' import Hero from '../components/services/Hero.tsx' import PriceList from '../components/services/PriceList.tsx' -import { getAllServices } from '../utils/services.function.ts' type ServiceListItem = { name: string @@ -17,55 +15,94 @@ type ServiceListCategory = { items: ServiceListItem[] } -const Services = () => { - const [allCategories, setAllCategories] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) - - useEffect(() => { - async function loadData() { - try { - setLoading(true) - const formatted = await getAllServices() - setAllCategories(formatted) - } catch (err) { - console.error('Failed to load services:', err) - setError('Failed to load services. Please try again later.') - } finally { - setLoading(false) - } - } - loadData() - }, []) +const serviceItem = (name: string, price: string): ServiceListItem => ({ + name, + desc: `${name} service`, + price, +}) - if (loading) { - return ( -
-
-
- ) - } - - if (error) { - return ( -
-
-

{error}

-
-
- ) - } +const serviceCategories: ServiceListCategory[] = [ + { + title: 'Pedicure', + items: [ + serviceItem('Day-by-Day pedicure', '$30'), + serviceItem('Hydra Glow pedicure', '$45'), + serviceItem('The Stella pedicure', '$65'), + serviceItem('Cosmic Veil pedicure', '$75'), + serviceItem('Seasonal pedicure', '$65'), + serviceItem('Star Dust pedicure', '$95'), + serviceItem('Additional Gel polish', '(+ $20)'), + ], + }, + { + title: 'Manicure', + items: [ + serviceItem('Day-by-Day manicure', '$20'), + serviceItem('Hydra Glow manicure', '$35'), + serviceItem('The Stella manicure', '$50'), + ], + }, + { + title: 'Nail Enhancement', + items: [ + serviceItem('Dipping Powder', '$40+'), + serviceItem('Acrylic Fullset', '$50+'), + serviceItem('Acrylic Fill', '$40+'), + serviceItem('Gel Manicure', '$40'), + serviceItem('Gel X', '$65+'), + serviceItem('Builder Gel', '$60'), + serviceItem('Tap Gel', '$60'), + ], + }, + { + title: 'Add-on services', + items: [ + serviceItem('French Tip', '$15+'), + serviceItem('Length', '$5+'), + serviceItem('Shaping', '$5+'), + serviceItem('Trim/reshape', '$10+'), + serviceItem('Soak off', '$15+'), + serviceItem('Regular polish change', '$15'), + serviceItem('Gel polish change', '$30'), + ], + }, + { + title: 'Waxing services', + items: [ + serviceItem('Eyebrows', '$12'), + serviceItem('Lip', '$10'), + serviceItem('Chin', '$10+'), + serviceItem('Sideburn', '$15+'), + serviceItem('Full Face', '$35+'), + serviceItem('Under arms', '$20+'), + serviceItem('Half Arms', '$30+'), + serviceItem('Full Arms', '$40+'), + serviceItem('Half Legs', '$40+'), + serviceItem('Full Legs', '$55+'), + ], + }, + { + title: 'Kids services', + items: [ + serviceItem('Kids pedicure', '$20'), + serviceItem('Kids manicure', '$15'), + serviceItem('Kids hands polish change', '$8'), + serviceItem('Kids toe polish change', '$10'), + ], + }, +] +const Services = () => { // Define specialty categories for custom sections - const enhancementCategory = allCategories.find((c) => + const enhancementCategory = serviceCategories.find((c) => c.title.toLowerCase().includes('enhancement'), ) - const beverageCategory = allCategories.find((c) => + const beverageCategory = serviceCategories.find((c) => c.title.toLowerCase().includes('beverage'), ) // Standard price list excludes specialty categories - const standardCategories = allCategories.filter( + const standardCategories = serviceCategories.filter( (c) => !c.title.toLowerCase().includes('enhancement') && !c.title.toLowerCase().includes('beverage'), diff --git a/apps/web/src/utils/dateStringHandler.ts b/apps/web/src/utils/dateStringHandler.ts index d212dfa..ad6438e 100644 --- a/apps/web/src/utils/dateStringHandler.ts +++ b/apps/web/src/utils/dateStringHandler.ts @@ -1,4 +1,4 @@ -const DEFAULT_TIME_ZONE = 'Asia/Bangkok' +const DEFAULT_TIME_ZONE = 'America/Chicago' type DateParts = { year: string diff --git a/deploy/lightsail/api.env.example b/deploy/lightsail/api.env.example index 340ae88..3594d66 100644 --- a/deploy/lightsail/api.env.example +++ b/deploy/lightsail/api.env.example @@ -8,5 +8,6 @@ JWT_SECRET=replace-me-with-a-long-random-secret CORS_ORIGINS=https://example.com COOKIE_SECURE=true COOKIE_SAME_SITE=lax +APP_TIMEZONE=America/Chicago MAIL_USER=your-ses-smtp-user MAIL_PASS=your-ses-smtp-password diff --git a/docs/deploy-lightsail.md b/docs/deploy-lightsail.md index 101d29c..c4363bb 100644 --- a/docs/deploy-lightsail.md +++ b/docs/deploy-lightsail.md @@ -23,7 +23,7 @@ cp deploy/lightsail/.env.example deploy/lightsail/.env cp deploy/lightsail/api.env.example deploy/lightsail/api.env ``` -Update both files with the production domain, database endpoint, credentials, JWT secret, and SMTP credentials. Lightsail PostgreSQL requires SSL in this setup, so keep `DB_SSL=true` in `deploy/lightsail/api.env`. +Update both files with the production domain, database endpoint, credentials, JWT secret, SMTP credentials, and salon timezone. Lightsail PostgreSQL requires SSL in this setup, so keep `DB_SSL=true` in `deploy/lightsail/api.env`; booking availability expects `APP_TIMEZONE=America/Chicago` unless the salon operating timezone changes. Build and publish the frontend files: