mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-07 23:01:06 +02:00
The scheduler only writes waiting_reason on the model-based assignment path, so a job pinned to a specific printer sits behind a running print with no marker at all. Every such job rendered an identical "starts now" ETA that was wrong by the length of everything ahead of it. Decide eligibility on the page instead: an item gets an ETA only when its printer is idle and it is the item the scheduler would dispatch next, following the same ordering the scheduler uses. Staged and future- scheduled items do not block the item behind them, matching the scheduler, and items conditional on a previous print are excluded. The value also froze at first render, since react-query's structural sharing keeps the queue reference stable and nothing re-rendered the row. formatETA now accepts a base instant and the page drives it from a 30s clock shared by every visible row. Retire the borrowed printers.estimatedCompletion tooltip for a queue key that says what the number means, translated into all 13 locales.
472 lines
14 KiB
TypeScript
472 lines
14 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 {
|
|
const resolved = dateFormat === 'system' ? detectSystemDateFormat() : dateFormat;
|
|
switch (resolved) {
|
|
case 'us': return 'MM/DD/YYYY';
|
|
case 'eu': return 'DD/MM/YYYY';
|
|
case 'iso': return 'YYYY-MM-DD';
|
|
default: return resolved satisfies never;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 {
|
|
for (const sep of ['/', '.', '-']) {
|
|
const parts = value.split(sep);
|
|
if (parts.length === 3) return parts;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function detectSystemDateFormat(): 'us' | 'eu' | 'iso' {
|
|
const formatted = new Date(2000, 11, 31).toLocaleDateString();
|
|
if (formatted.startsWith('12')) return 'us';
|
|
if (formatted.startsWith('31')) return 'eu';
|
|
return 'iso';
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
const parts = splitDateParts(value);
|
|
if (!parts) return null;
|
|
|
|
const resolved = dateFormat === 'system' ? detectSystemDateFormat() : dateFormat;
|
|
let day: number, month: number, year: number;
|
|
|
|
switch (resolved) {
|
|
case 'us':
|
|
month = parseInt(parts[0], 10);
|
|
day = parseInt(parts[1], 10);
|
|
year = parseInt(parts[2], 10);
|
|
break;
|
|
case 'eu':
|
|
day = parseInt(parts[0], 10);
|
|
month = parseInt(parts[1], 10);
|
|
year = parseInt(parts[2], 10);
|
|
break;
|
|
case 'iso':
|
|
year = parseInt(parts[0], 10);
|
|
month = parseInt(parts[1], 10);
|
|
day = parseInt(parts[2], 10);
|
|
break;
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
|
|
const trimmed = value.trim();
|
|
|
|
const match = trimmed.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i);
|
|
if (!match) return null;
|
|
|
|
let hours = parseInt(match[1], 10);
|
|
const minutes = parseInt(match[2], 10);
|
|
const ampm = match[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 };
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
* @param baseTime - Instant to count from, in epoch ms. Defaults to the current
|
|
* clock. Callers that render an ETA for something not yet started must pass a
|
|
* value that changes over time, or the string freezes at first render: it is
|
|
* only recomputed when the component re-renders, which does not happen while
|
|
* the underlying data is unchanged (#2740).
|
|
* @returns Formatted ETA string (e.g., "3:45 PM", "Tomorrow 9:30 AM", "Wed 2:00 PM")
|
|
*/
|
|
export function formatETA(
|
|
remainingMinutes: number,
|
|
timeFormat: TimeFormat = 'system',
|
|
t?: (key: string) => string,
|
|
baseTime?: number
|
|
): string {
|
|
const now = baseTime != null ? new Date(baseTime) : new Date();
|
|
const eta = new Date(now.getTime() + remainingMinutes * 60 * 1000);
|
|
|
|
const today = new Date(now);
|
|
today.setHours(0, 0, 0, 0);
|
|
const etaDay = new Date(eta);
|
|
etaDay.setHours(0, 0, 0, 0);
|
|
|
|
const timeOptions = applyTimeFormat({ hour: '2-digit', minute: '2-digit' }, timeFormat);
|
|
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`;
|
|
}
|
|
|
|
type TranslateFunction = (key: string, options?: Record<string, unknown>) => string;
|
|
|
|
/**
|
|
* Format a date string as a human-readable relative time expression.
|
|
*
|
|
* @param dateStr - UTC date string, or null
|
|
* @param timeFormat - Time format preference ('12h', '24h', or 'system')
|
|
* @param t - Optional translation function for i18n support
|
|
* @returns Relative string (e.g., "5m ago", "in 2h", "3d ago") or formatted date if older than 7 days
|
|
*/
|
|
export function formatRelativeTime(
|
|
dateStr: string | null,
|
|
timeFormat: TimeFormat = 'system',
|
|
t?: TranslateFunction
|
|
): string {
|
|
if (!dateStr) return t?.('time.unknown') ?? '-';
|
|
|
|
const date = parseUTCDate(dateStr);
|
|
if (!date) return t?.('time.unknown') ?? '-';
|
|
|
|
const now = new Date();
|
|
const diffMs = date.getTime() - now.getTime();
|
|
const isPast = diffMs < 0;
|
|
const absDiffMs = Math.abs(diffMs);
|
|
|
|
const minutes = Math.floor(absDiffMs / 60000);
|
|
const hours = Math.floor(absDiffMs / 3600000);
|
|
const days = Math.floor(absDiffMs / 86400000);
|
|
|
|
// Less than 1 minute
|
|
if (minutes < 1) {
|
|
return isPast
|
|
? t?.('time.justNow') ?? 'Just now'
|
|
: t?.('time.now') ?? 'Now';
|
|
}
|
|
|
|
// Less than 1 hour
|
|
if (hours < 1) {
|
|
return isPast
|
|
? t?.('time.minsAgo', { count: minutes }) ?? `${minutes}m ago`
|
|
: t?.('time.inMins', { count: minutes }) ?? `in ${minutes}m`;
|
|
}
|
|
|
|
// Less than 1 day
|
|
if (days < 1) {
|
|
return isPast
|
|
? t?.('time.hoursAgo', { count: hours }) ?? `${hours}h ago`
|
|
: t?.('time.inHours', { count: hours }) ?? `in ${hours}h`;
|
|
}
|
|
|
|
// Less than 7 days
|
|
if (days < 7) {
|
|
return isPast
|
|
? t?.('time.daysAgo', { count: days }) ?? `${days}d ago`
|
|
: t?.('time.inDays', { count: days }) ?? `in ${days}d`;
|
|
}
|
|
|
|
// Older than 7 days
|
|
return formatDateTime(dateStr, timeFormat);
|
|
}
|
|
|
|
/**
|
|
* Format seconds as MM:SS for media/video player display.
|
|
*
|
|
* @param seconds - Total seconds
|
|
* @returns Formatted string (e.g., "2:05", "0:30")
|
|
*/
|
|
export function formatMediaTime(seconds: number): string {
|
|
const mins = Math.floor(seconds / 60);
|
|
const secs = Math.floor(seconds % 60);
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
}
|
|
|
|
/**
|
|
* Format a duration given in hours to a human-readable string.
|
|
*
|
|
* @param hours - Duration in hours (e.g., 2.5)
|
|
* @returns Formatted string (e.g., "2h 30m", "45m", "3h")
|
|
*/
|
|
export function formatDurationFromHours(hours: number): string {
|
|
if (hours < 1) return `${Math.round(hours * 60)}m`;
|
|
const h = Math.floor(hours);
|
|
const m = Math.round((hours - h) * 60);
|
|
return m > 0 ? `${h}h ${m}m` : `${h}h`;
|
|
}
|
|
|
|
/**
|
|
* Build a YYYY-MM-DD key for a date, evaluated in the user's local timezone.
|
|
*
|
|
* The naive `iso.split('T')[0]` shortcut gives the UTC date, which buckets
|
|
* an evening print in a negative-UTC-offset region (e.g. CDT) onto the
|
|
* following day. Stats / heatmap bucketing is a presentation concern and
|
|
* the browser knows the user's tz, so we format with the local getters
|
|
* here. Use `toLocaleDateString` for *displaying* a date — this helper is
|
|
* for *keying* buckets, where the value needs to be a stable comparable
|
|
* string regardless of locale conventions.
|
|
*/
|
|
export function localDateKey(input: string | Date): string {
|
|
const date = typeof input === 'string' ? parseUTCDate(input) : input;
|
|
if (!date) return '';
|
|
const y = date.getFullYear();
|
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
|
const d = String(date.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|