Dispatcharr/frontend/src/utils/dateTimeUtils.js

356 lines
9.9 KiB
JavaScript

import { useCallback, useEffect } from 'react';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import relativeTime from 'dayjs/plugin/relativeTime';
import utc from 'dayjs/plugin/utc';
import timezone from 'dayjs/plugin/timezone';
import useSettingsStore from '../store/settings';
import useLocalStorage from '../hooks/useLocalStorage';
dayjs.extend(duration);
dayjs.extend(relativeTime);
dayjs.extend(utc);
dayjs.extend(timezone);
export const convertToMs = (dateTime) => dayjs(dateTime).valueOf();
export const convertToSec = (dateTime) => dayjs(dateTime).unix();
export const initializeTime = (dateTime) => dayjs(dateTime);
export const startOfDay = (dateTime) => dayjs(dateTime).startOf('day');
export const isBefore = (date1, date2) => dayjs(date1).isBefore(date2);
export const isAfter = (date1, date2) => dayjs(date1).isAfter(date2);
export const isSame = (date1, date2, unit = 'day') =>
dayjs(date1).isSame(date2, unit);
export const add = (dateTime, value, unit) => dayjs(dateTime).add(value, unit);
export const subtract = (dateTime, value, unit) =>
dayjs(dateTime).subtract(value, unit);
export const diff = (date1, date2, unit = 'millisecond') =>
dayjs(date1).diff(date2, unit);
export const format = (dateTime, formatStr) =>
dayjs(dateTime).format(formatStr);
export const getNow = () => dayjs();
export const toFriendlyDuration = (dateTime, unit) =>
dayjs.duration(dateTime, unit).humanize();
export const formatExactDuration = (seconds) => {
if (seconds < 60) return `${seconds.toFixed(1)} seconds`;
if (seconds < 3600) {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m} minute${m !== 1 ? 's' : ''}, ${s} second${s !== 1 ? 's' : ''}`;
}
if (seconds < 86400) {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h} hour${h !== 1 ? 's' : ''}, ${m} minute${m !== 1 ? 's' : ''}`;
}
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
return `${d} day${d !== 1 ? 's' : ''}, ${h} hour${h !== 1 ? 's' : ''}`;
};
export const fromNow = (dateTime) => dayjs(dateTime).fromNow();
export const setTz = (dateTime, timeZone) => dayjs(dateTime).tz(timeZone);
export const setMonth = (dateTime, value) => dayjs(dateTime).month(value);
export const setYear = (dateTime, value) => dayjs(dateTime).year(value);
export const setDay = (dateTime, value) => dayjs(dateTime).date(value);
export const setHour = (dateTime, value) => dayjs(dateTime).hour(value);
export const setMinute = (dateTime, value) => dayjs(dateTime).minute(value);
export const setSecond = (dateTime, value) => dayjs(dateTime).second(value);
export const getMonth = (dateTime) => dayjs(dateTime).month();
export const getYear = (dateTime) => dayjs(dateTime).year();
export const getDay = (dateTime) => dayjs(dateTime).date();
export const getHour = (dateTime) => dayjs(dateTime).hour();
export const getMinute = (dateTime) => dayjs(dateTime).minute();
export const getSecond = (dateTime) => dayjs(dateTime).second();
export const getNowMs = () => Date.now();
export const roundToNearest = (dateTime, minutes) => {
const current = initializeTime(dateTime);
const minute = current.minute();
const snappedMinute = Math.round(minute / minutes) * minutes;
return snappedMinute === 60
? current.add(1, 'hour').minute(0)
: current.minute(snappedMinute);
};
export const useUserTimeZone = () => {
const settings = useSettingsStore((s) => s.settings);
const [timeZone, setTimeZone] = useLocalStorage(
'time-zone',
dayjs.tz?.guess
? dayjs.tz.guess()
: Intl.DateTimeFormat().resolvedOptions().timeZone
);
useEffect(() => {
const tz = settings?.['system_settings']?.value?.time_zone;
if (tz && tz !== timeZone) {
setTimeZone(tz);
}
}, [settings, timeZone, setTimeZone]);
return timeZone;
};
export const useTimeHelpers = () => {
const timeZone = useUserTimeZone();
const toUserTime = useCallback(
(value) => {
if (!value) return dayjs(null);
try {
return initializeTime(value).tz(timeZone);
} catch (error) {
return initializeTime(value);
}
},
[timeZone]
);
const userNow = useCallback(() => getNow().tz(timeZone), [timeZone]);
return { timeZone, toUserTime, userNow };
};
export const RECURRING_DAY_OPTIONS = [
{ value: 6, label: 'Sun' },
{ value: 0, label: 'Mon' },
{ value: 1, label: 'Tue' },
{ value: 2, label: 'Wed' },
{ value: 3, label: 'Thu' },
{ value: 4, label: 'Fri' },
{ value: 5, label: 'Sat' },
];
export const useDateTimeFormat = () => {
const [timeFormatSetting] = useLocalStorage('time-format', '12h');
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
// Use user preference for time format
const timeFormat = timeFormatSetting === '12h' ? 'h:mma' : 'HH:mm';
const dateFormat = dateFormatSetting === 'mdy' ? 'MMM D' : 'D MMM';
// Full format strings for detailed date-time displays
const fullDateFormat =
dateFormatSetting === 'mdy' ? 'MM/DD/YYYY' : 'DD/MM/YYYY';
const fullTimeFormat = timeFormatSetting === '12h' ? 'h:mm:ss A' : 'HH:mm:ss';
const fullDateTimeFormat = `${fullDateFormat}, ${fullTimeFormat}`;
return {
timeFormat,
dateFormat,
fullDateFormat,
fullTimeFormat,
fullDateTimeFormat,
// Also return raw settings for cases that need them
timeFormatSetting,
dateFormatSetting,
};
};
export const toTimeString = (value) => {
if (!value) return '00:00';
if (typeof value === 'string') {
const parsed = dayjs(value, ['HH:mm', 'HH:mm:ss', 'h:mm A'], true);
if (parsed.isValid()) return parsed.format('HH:mm');
return value;
}
const parsed = initializeTime(value);
return parsed.isValid() ? parsed.format('HH:mm') : '00:00';
};
export const parseDate = (value) => {
if (!value) return null;
const parsed = dayjs(value, ['YYYY-MM-DD', dayjs.ISO_8601], true);
return parsed.isValid() ? parsed.toDate() : null;
};
const TIMEZONE_FALLBACKS = [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'America/Phoenix',
'America/Anchorage',
'Pacific/Honolulu',
'Europe/London',
'Europe/Paris',
'Europe/Berlin',
'Europe/Madrid',
'Europe/Warsaw',
'Europe/Moscow',
'Asia/Dubai',
'Asia/Kolkata',
'Asia/Shanghai',
'Asia/Tokyo',
'Asia/Seoul',
'Australia/Sydney',
];
const getSupportedTimeZones = () => {
try {
if (typeof Intl.supportedValuesOf === 'function') {
return Intl.supportedValuesOf('timeZone');
}
} catch (error) {
console.warn('Unable to enumerate supported time zones:', error);
}
return TIMEZONE_FALLBACKS;
};
const getTimeZoneOffsetMinutes = (date, timeZone) => {
try {
const dtf = new Intl.DateTimeFormat('en-US', {
timeZone,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23',
});
const parts = dtf.formatToParts(date).reduce((acc, part) => {
if (part.type !== 'literal') acc[part.type] = part.value;
return acc;
}, {});
const asUTC = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second)
);
return (asUTC - date.getTime()) / 60000;
} catch (error) {
console.warn(`Failed to compute offset for ${timeZone}:`, error);
return 0;
}
};
const formatOffset = (minutes) => {
const rounded = Math.round(minutes);
const sign = rounded < 0 ? '-' : '+';
const absolute = Math.abs(rounded);
const hours = String(Math.floor(absolute / 60)).padStart(2, '0');
const mins = String(absolute % 60).padStart(2, '0');
return `UTC${sign}${hours}:${mins}`;
};
export const buildTimeZoneOptions = (preferredZone) => {
const zones = getSupportedTimeZones();
const referenceYear = new Date().getUTCFullYear();
const janDate = new Date(Date.UTC(referenceYear, 0, 1, 12, 0, 0));
const julDate = new Date(Date.UTC(referenceYear, 6, 1, 12, 0, 0));
const options = zones
.map((zone) => {
const janOffset = getTimeZoneOffsetMinutes(janDate, zone);
const julOffset = getTimeZoneOffsetMinutes(julDate, zone);
const currentOffset = getTimeZoneOffsetMinutes(new Date(), zone);
const minOffset = Math.min(janOffset, julOffset);
const maxOffset = Math.max(janOffset, julOffset);
const usesDst = minOffset !== maxOffset;
const labelParts = [`now ${formatOffset(currentOffset)}`];
if (usesDst) {
labelParts.push(
`DST range ${formatOffset(minOffset)} to ${formatOffset(maxOffset)}`
);
}
return {
value: zone,
label: `${zone} (${labelParts.join(' | ')})`,
numericOffset: minOffset,
};
})
.sort((a, b) => {
if (a.numericOffset !== b.numericOffset) {
return a.numericOffset - b.numericOffset;
}
return a.value.localeCompare(b.value);
});
if (
preferredZone &&
!options.some((option) => option.value === preferredZone)
) {
const currentOffset = getTimeZoneOffsetMinutes(new Date(), preferredZone);
options.push({
value: preferredZone,
label: `${preferredZone} (now ${formatOffset(currentOffset)})`,
numericOffset: currentOffset,
});
options.sort((a, b) => {
if (a.numericOffset !== b.numericOffset) {
return a.numericOffset - b.numericOffset;
}
return a.value.localeCompare(b.value);
});
}
return options;
};
export const getDefaultTimeZone = () => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch (error) {
return 'UTC';
}
};
export const MONTH_NAMES = [
'january',
'february',
'march',
'april',
'may',
'june',
'july',
'august',
'september',
'october',
'november',
'december',
];
export const MONTH_ABBR = [
'jan',
'feb',
'mar',
'apr',
'may',
'jun',
'jul',
'aug',
'sep',
'oct',
'nov',
'dec',
];