Untitled

Anonymous
plain_text
03/04/2026 8:24 AM
7.9 KB
12
Indexable
import type { ProfileV3WorkingHour } from '../types';

const WEEK_DAYS = [
  'sunday',
  'monday',
  'tuesday',
  'wednesday',
  'thursday',
  'friday',
  'saturday',
] as const;

type ProviderNow = {
  weekday: string;
  year: number;
  month: number;
  day: number;
  minutesOfDay: number;
};

type InternalOpenStatus = {
  isOpen: boolean;
  closesAtMinutes?: number;
  closeDayOffset?: number;
};

export type WorkingHoursNowStatus = {
  isOpen: boolean;
  closesAt: string | null;
};

export const formatWorkingHoursDay = (
  value: string,
  weekdaysLabel: string,
  nationalHolidaysLabel: string,
) => {
  const normalized = value.replace(/[_-]+/g, ' ').trim();
  const lower = normalized.toLowerCase();

  if (
    lower.includes('weekday') ||
    lower === 'mon fri' ||
    lower === 'monday friday'
  ) {
    return weekdaysLabel;
  }

  if (lower === 'national holiday' || lower === 'national holidays') {
    return nationalHolidaysLabel;
  }

  return normalized.replace(/\b\w/g, char => char.toUpperCase());
};

const getTimeZoneOffsetMinutes = (
  date: Date,
  timeZone: string,
): number | null => {
  try {
    const formatter = new Intl.DateTimeFormat('en-US', {
      timeZone,
      timeZoneName: 'shortOffset',
      hour: '2-digit',
      minute: '2-digit',
      hourCycle: 'h23',
    });
    const part = formatter
      .formatToParts(date)
      .find(item => item.type === 'timeZoneName')?.value;
    if (!part) return null;

    const match = part.match(/GMT([+-]\d{1,2})(?::?(\d{2}))?/i);
    if (!match) return null;

    const hours = Number(match[1]);
    const minutes = Number(match[2] ?? '0');
    if (!Number.isFinite(hours) || !Number.isFinite(minutes)) return null;
    return hours * 60 + Math.sign(hours || 1) * minutes;
  } catch {
    return null;
  }
};

const getProviderNow = (timeZone: string): ProviderNow | null => {
  const now = new Date();
  const formatter = new Intl.DateTimeFormat('en-US', {
    timeZone,
    weekday: 'long',
    year: 'numeric',
    month: 'numeric',
    day: 'numeric',
    hour: '2-digit',
    minute: '2-digit',
    hourCycle: 'h23',
  });
  const parts = formatter.formatToParts(now);

  const get = (type: Intl.DateTimeFormatPartTypes) =>
    parts.find(item => item.type === type)?.value || '';

  const weekday = get('weekday').toLowerCase();
  const year = Number(get('year'));
  const month = Number(get('month'));
  const day = Number(get('day'));
  const hour = Number(get('hour'));
  const minute = Number(get('minute'));
  const minutesOfDay = hour * 60 + minute;

  if (
    !Number.isFinite(year) ||
    !Number.isFinite(month) ||
    !Number.isFinite(day) ||
    !Number.isFinite(hour) ||
    !Number.isFinite(minute)
  ) {
    return null;
  }

  return { weekday, year, month, day, minutesOfDay };
};

const buildAbsoluteDateFromProviderLocal = (
  providerNow: ProviderNow | null,
  timeZone: string,
  dayOffset: number,
  minutesOfDay: number,
): Date | null => {
  if (!providerNow) return null;

  const hour = Math.floor(minutesOfDay / 60);
  const minute = minutesOfDay % 60;
  const approxUtc = Date.UTC(
    providerNow.year,
    providerNow.month - 1,
    providerNow.day + dayOffset,
    hour,
    minute,
    0,
  );
  const offsetMinutes = getTimeZoneOffsetMinutes(new Date(approxUtc), timeZone);
  if (typeof offsetMinutes !== 'number') return null;

  return new Date(approxUtc - offsetMinutes * 60 * 1000);
};

const getOpenStatusForNow = (
  workingHours: ProfileV3WorkingHour[],
  timeZone: string,
): InternalOpenStatus => {
  const providerNow = getProviderNow(timeZone);
  if (!providerNow) return { isOpen: false };

  const currentDayIndex = WEEK_DAYS.findIndex(day => day === providerNow.weekday);
  if (currentDayIndex < 0) return { isOpen: false };

  const today = workingHours.find(
    item => item.day.toLowerCase() === WEEK_DAYS[currentDayIndex],
  );
  const yesterdayIndex = (currentDayIndex + 6) % 7;
  const yesterday = workingHours.find(
    item => item.day.toLowerCase() === WEEK_DAYS[yesterdayIndex],
  );
  const nowMinutes = providerNow.minutesOfDay;

  if (today?.isOpen24Hours) {
    return { isOpen: true };
  }

  if (
    today?.isOpen &&
    Number.isFinite(today.opensAtMinutes) &&
    Number.isFinite(today.closesAtMinutes)
  ) {
    const open = Number(today.opensAtMinutes);
    const close = Number(today.closesAtMinutes);

    if (close > open && nowMinutes >= open && nowMinutes < close) {
      return { isOpen: true, closesAtMinutes: close, closeDayOffset: 0 };
    }

    if (close < open && nowMinutes >= open) {
      return { isOpen: true, closesAtMinutes: close, closeDayOffset: 1 };
    }
  }

  if (
    yesterday?.isOpen &&
    Number.isFinite(yesterday.opensAtMinutes) &&
    Number.isFinite(yesterday.closesAtMinutes)
  ) {
    const yesterdayOpen = Number(yesterday.opensAtMinutes);
    const yesterdayClose = Number(yesterday.closesAtMinutes);
    const isOvernight = yesterdayClose < yesterdayOpen;

    if (isOvernight && nowMinutes < yesterdayClose) {
      return {
        isOpen: true,
        closesAtMinutes: yesterdayClose,
        closeDayOffset: 0,
      };
    }
  }

  return { isOpen: false };
};

export const resolveWorkingHoursNowStatus = (
  workingHours: ProfileV3WorkingHour[],
  providerTimeZone: string,
  locale?: string,
): WorkingHoursNowStatus => {
  const openStatus = getOpenStatusForNow(workingHours, providerTimeZone);
  if (!openStatus.isOpen || !Number.isFinite(openStatus.closesAtMinutes)) {
    return { isOpen: openStatus.isOpen, closesAt: null };
  }

  const providerNow = getProviderNow(providerTimeZone);
  const absoluteCloseDate = buildAbsoluteDateFromProviderLocal(
    providerNow,
    providerTimeZone,
    openStatus.closeDayOffset ?? 0,
    Number(openStatus.closesAtMinutes),
  );
  if (!absoluteCloseDate) {
    return { isOpen: openStatus.isOpen, closesAt: null };
  }

  const closesAt = new Intl.DateTimeFormat(locale, {
    hour: 'numeric',
    minute: '2-digit',
  }).format(absoluteCloseDate);

  return { isOpen: openStatus.isOpen, closesAt };
};
Editor is loading...
Leave a Comment