Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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.

22 changes: 13 additions & 9 deletions apps/api/src/controllers/appointment.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});

Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/services/_tests_/appointment.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ describe("Appointment Service", () => {

const result = await appointmentService.findConfirmedAppointmentsForCheckIn(
"5551234567",
"2026-04-13"
"2026-04-12"
);

expect(Appointment.findAll).toHaveBeenNthCalledWith(2, {
Expand Down
44 changes: 17 additions & 27 deletions apps/api/src/services/appointment.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/utils/_tests_/timezone.util.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
182 changes: 182 additions & 0 deletions apps/api/src/utils/timezone.util.ts
Original file line number Diff line number Diff line change
@@ -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 };
1 change: 0 additions & 1 deletion apps/web/src/components/services/ServiceCategory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ const ServiceCategory = ({ title, items }: ServiceCategoryProps) => (
<div className="flex items-start justify-between gap-6 transition-colors">
<h4 className="font-serif text-xl font-bold tracking-tight text-[#3e2723] md:text-2xl">
{item.name}
{!item.name.endsWith(':') && ':'}
</h4>
<div className="shrink-0 font-serif text-xl font-bold text-[#3e2723] md:text-2xl">
{item.price}
Expand Down
Loading
Loading