Files
bambuddy/frontend/src/utils/date.ts
T
AneoPsy 687a48e655 feat(queue): enhance currently printing item display
- Add progress bar, time remaining, ETA, and layer count to currently printing item
- Remove duplicate formatDuration & formatETA functions, now imported from utils/dates
- Remove formatTime (duplicate of formatDuration)
- Update formatETA to support optional i18n translation
- Update translation: common.tomorrow
2026-02-15 22:25:52 -10:00

426 lines
13 KiB
TypeScript

/**
* Date utilities for handling UTC timestamps from the backend.
*
* The backend stores all timestamps in UTC without timezone indicators.
* These utilities ensure dates are properly interpreted as UTC and
* displayed in the user's local timezone.
*/
export type TimeFormat = 'system' | '12h' | '24h';
export type DateFormat = 'system' | 'us' | 'eu' | 'iso';
/**
* Get the date input placeholder based on format setting.
*/
export function getDatePlaceholder(dateFormat: DateFormat = 'system'): string {
switch (dateFormat) {
case 'us':
return 'MM/DD/YYYY';
case 'eu':
return 'DD/MM/YYYY';
case 'iso':
return 'YYYY-MM-DD';
case 'system':
default: {
// Try to detect system format
const testDate = new Date(2000, 11, 31); // Dec 31, 2000
const formatted = testDate.toLocaleDateString();
if (formatted.startsWith('12')) return 'MM/DD/YYYY';
if (formatted.startsWith('31')) return 'DD/MM/YYYY';
return 'YYYY-MM-DD';
}
}
}
/**
* Get the time input placeholder based on format setting.
*/
export function getTimePlaceholder(timeFormat: TimeFormat = 'system'): string {
switch (timeFormat) {
case '12h':
return 'HH:MM AM/PM';
case '24h':
return 'HH:MM';
case 'system':
default: {
// Try to detect system format
const testDate = new Date(2000, 0, 1, 14, 30);
const formatted = testDate.toLocaleTimeString();
if (formatted.includes('PM') || formatted.includes('AM')) return 'HH:MM AM/PM';
return 'HH:MM';
}
}
}
/**
* Format a Date object to a date string based on format setting.
*/
export function formatDateInput(date: Date, dateFormat: DateFormat = 'system'): string {
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
switch (dateFormat) {
case 'us':
return `${month}/${day}/${year}`;
case 'eu':
return `${day}/${month}/${year}`;
case 'iso':
return `${year}-${month}-${day}`;
case 'system':
default:
return date.toLocaleDateString();
}
}
/**
* Format a Date object to a time string based on format setting.
*/
export function formatTimeInput(date: Date, timeFormat: TimeFormat = 'system'): string {
const hours24 = date.getHours();
const minutes = String(date.getMinutes()).padStart(2, '0');
switch (timeFormat) {
case '12h': {
const hours12 = hours24 % 12 || 12;
const ampm = hours24 < 12 ? 'AM' : 'PM';
return `${hours12}:${minutes} ${ampm}`;
}
case '24h':
return `${String(hours24).padStart(2, '0')}:${minutes}`;
case 'system':
default:
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
}
/**
* Split a date string by common separators (/, ., -).
*/
function splitDateParts(value: string): string[] | null {
// Try common separators: /, ., -
for (const sep of ['/', '.', '-']) {
const parts = value.split(sep);
if (parts.length === 3) return parts;
}
return null;
}
/**
* Parse a date string based on format setting.
* Returns null if parsing fails.
* Supports common separators: / . -
*/
export function parseDateInput(value: string, dateFormat: DateFormat = 'system'): Date | null {
if (!value) return null;
let day: number, month: number, year: number;
try {
switch (dateFormat) {
case 'us': {
// MM/DD/YYYY (also accepts . and - separators)
const parts = splitDateParts(value);
if (!parts) return null;
month = parseInt(parts[0], 10);
day = parseInt(parts[1], 10);
year = parseInt(parts[2], 10);
break;
}
case 'eu': {
// DD/MM/YYYY (also accepts . and - separators)
const parts = splitDateParts(value);
if (!parts) return null;
day = parseInt(parts[0], 10);
month = parseInt(parts[1], 10);
year = parseInt(parts[2], 10);
break;
}
case 'iso': {
// YYYY-MM-DD (also accepts . and / separators)
const parts = splitDateParts(value);
if (!parts) return null;
year = parseInt(parts[0], 10);
month = parseInt(parts[1], 10);
day = parseInt(parts[2], 10);
break;
}
case 'system':
default: {
// Detect system format and parse accordingly
const testDate = new Date(2000, 11, 31); // Dec 31, 2000
const formatted = testDate.toLocaleDateString();
const parts = splitDateParts(value);
if (parts) {
// Detect format from system locale
if (formatted.startsWith('12')) {
// US format: MM/DD/YYYY
month = parseInt(parts[0], 10);
day = parseInt(parts[1], 10);
year = parseInt(parts[2], 10);
} else if (formatted.startsWith('31')) {
// EU format: DD/MM/YYYY
day = parseInt(parts[0], 10);
month = parseInt(parts[1], 10);
year = parseInt(parts[2], 10);
} else {
// ISO format: YYYY-MM-DD
year = parseInt(parts[0], 10);
month = parseInt(parts[1], 10);
day = parseInt(parts[2], 10);
}
break;
}
return null;
}
}
if (isNaN(day) || isNaN(month) || isNaN(year)) return null;
if (month < 1 || month > 12) return null;
if (day < 1 || day > 31) return null;
if (year < 1900 || year > 2100) return null;
return new Date(year, month - 1, day);
} catch {
return null;
}
}
/**
* Parse a time string. Handles both 12h (with AM/PM) and 24h formats.
* Returns { hours, minutes } or null if parsing fails.
*/
export function parseTimeInput(value: string): { hours: number; minutes: number } | null {
if (!value) return null;
try {
const trimmed = value.trim().toUpperCase();
// Check for 12h format with AM/PM
const ampmMatch = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i);
if (ampmMatch) {
let hours = parseInt(ampmMatch[1], 10);
const minutes = parseInt(ampmMatch[2], 10);
const ampm = ampmMatch[3]?.toUpperCase();
if (ampm === 'PM' && hours < 12) hours += 12;
if (ampm === 'AM' && hours === 12) hours = 0;
if (hours < 0 || hours > 23) return null;
if (minutes < 0 || minutes > 59) return null;
return { hours, minutes };
}
// Try 24h format HH:MM
const match24 = trimmed.match(/^(\d{1,2}):(\d{2})$/);
if (match24) {
const hours = parseInt(match24[1], 10);
const minutes = parseInt(match24[2], 10);
if (hours < 0 || hours > 23) return null;
if (minutes < 0 || minutes > 59) return null;
return { hours, minutes };
}
return null;
} catch {
return null;
}
}
/**
* Convert a Date object to datetime-local input value (ISO format).
*/
export function toDateTimeLocalValue(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
/**
* Apply time format setting to Intl.DateTimeFormatOptions.
* This modifies the options object in place and returns it.
*/
export function applyTimeFormat(
options: Intl.DateTimeFormatOptions,
timeFormat: TimeFormat = 'system'
): Intl.DateTimeFormatOptions {
if (timeFormat === '12h') {
options.hour12 = true;
} else if (timeFormat === '24h') {
options.hour12 = false;
}
// 'system' leaves hour12 undefined, letting the browser decide
return options;
}
/**
* Parse a date string from the backend as UTC.
* Handles ISO 8601 strings with or without timezone indicators.
*
* @param dateStr - Date string from backend (e.g., "2026-01-09T12:03:36.288768")
* @returns Date object in local timezone
*/
export function parseUTCDate(dateStr: string | null | undefined): Date | null {
if (!dateStr) return null;
// If the string already has a timezone indicator, parse as-is
if (dateStr.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(dateStr)) {
return new Date(dateStr);
}
// Otherwise, append 'Z' to interpret as UTC
return new Date(dateStr + 'Z');
}
/**
* Format a UTC date string to a localized date/time string.
*
* @param dateStr - Date string from backend
* @param options - Intl.DateTimeFormat options (defaults to showing date and time)
* @returns Formatted date string in user's locale and timezone
*/
export function formatDate(
dateStr: string | null | undefined,
options?: Intl.DateTimeFormatOptions
): string {
const date = parseUTCDate(dateStr);
if (!date) return '';
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
return date.toLocaleString(undefined, options ?? defaultOptions);
}
/**
* Format a UTC date string to a localized date-only string.
*
* @param dateStr - Date string from backend
* @param options - Intl.DateTimeFormat options
* @returns Formatted date string in user's locale and timezone
*/
export function formatDateOnly(
dateStr: string | null | undefined,
options?: Intl.DateTimeFormatOptions
): string {
const date = parseUTCDate(dateStr);
if (!date) return '';
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
};
return date.toLocaleDateString(undefined, options ?? defaultOptions);
}
/**
* Format a UTC date string to a localized date/time string with time format support.
*
* @param dateStr - Date string from backend
* @param timeFormat - Time format setting ('system', '12h', '24h')
* @param options - Intl.DateTimeFormat options (defaults to showing date and time)
* @returns Formatted date string in user's locale and timezone
*/
export function formatDateTime(
dateStr: string | null | undefined,
timeFormat: TimeFormat = 'system',
options?: Intl.DateTimeFormatOptions
): string {
const date = parseUTCDate(dateStr);
if (!date) return '';
const defaultOptions: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
};
const finalOptions = applyTimeFormat(options ?? defaultOptions, timeFormat);
return date.toLocaleString(undefined, finalOptions);
}
/**
* Format a Date object to a localized time string with time format support.
*
* @param date - Date object
* @param timeFormat - Time format setting ('system', '12h', '24h')
* @param options - Additional Intl.DateTimeFormat options
* @returns Formatted time string
*/
export function formatTimeOnly(
date: Date,
timeFormat: TimeFormat = 'system',
options?: Intl.DateTimeFormatOptions
): string {
const defaultOptions: Intl.DateTimeFormatOptions = {
hour: '2-digit',
minute: '2-digit',
};
const finalOptions = applyTimeFormat({ ...defaultOptions, ...options }, timeFormat);
return date.toLocaleTimeString([], finalOptions);
}
/**
* Calculate and format an ETA based on remaining minutes from now.
*
* @param remainingMinutes - Minutes until completion
* @param timeFormat - Time format setting ('system', '12h', '24h')
* @param t - Optional i18n translation function
* @returns Formatted ETA string (e.g., "3:45 PM", "Tomorrow 9:30 AM", "Wed 2:00 PM")
*/
export function formatETA(
remainingMinutes: number,
timeFormat: 'system' | '12h' | '24h' = 'system',
t?: (key: string) => string
): string {
const now = new Date();
const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
const today = new Date();
today.setHours(0, 0, 0, 0);
const etaDay = new Date(eta);
etaDay.setHours(0, 0, 0, 0);
const timeOptions: Intl.DateTimeFormatOptions = { hour: '2-digit', minute: '2-digit' };
if (timeFormat === '12h') timeOptions.hour12 = true;
else if (timeFormat === '24h') timeOptions.hour12 = false;
const timeStr = eta.toLocaleTimeString([], timeOptions);
const dayDiff = Math.floor((etaDay.getTime() - today.getTime()) / 86400000);
if (dayDiff === 0) return timeStr;
if (dayDiff === 1) return `${t?.('common.tomorrow') ?? 'Tomorrow'} ${timeStr}`;
return `${eta.toLocaleDateString([], { weekday: 'short' })} ${timeStr}`;
}
/**
* Format a duration in seconds to a human-readable string, with null handling.
*
* @param seconds - Duration in seconds, or null/undefined
* @returns Formatted string (e.g., "2h 30m", "45m") or "--" if no value
*/
export function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || seconds < 0) return '--';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}m`;
}