mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-02 12:15:36 +02:00
PrintCalendar.tsx had three instances of the same UTC-shortcut anti-pattern:
1. Bucketing input dates via `date.split('T')[0]` — gives the UTC day
while the cell tooltip rendered local via `toLocaleDateString`. Same
data, two renderers, only one was tz-correct. Reporter on CDT (UTC-5)
saw evening prints jump to "tomorrow's" cell.
2. Per-cell lookup key built via `day.toISOString().split('T')[0]`. The
`day` Date objects produced by the calendar-generation loop are
local-tz (constructed via `new Date()` + `setDate`), so `toISOString`
shifted them back to UTC before the lookup — would have re-broken the
join even after the bucketing fix.
3. "Today" ring comparison used `new Date().toISOString().split('T')[0]`
too — at 23:00 local the ring would have moved to UTC-tomorrow's cell.
Fix adds a `localDateKey(input: string | Date): string` helper to
utils/date.ts that wraps parseUTCDate and formats via the local-tz
getters (`getFullYear` / `getMonth` / `getDate` with two-digit padding),
returning a stable comparable YYYY-MM-DD. PrintCalendar.tsx uses it in
all three spots so the buckets, the cell join, and the today ring share
the same local-tz axis as the user's tooltip label.
Backend stays UTC. Bucketing is a presentation concern and the browser
already knows the user's tz.
Stragglers flagged for follow-up: StatsPage.computeDateRange builds
dateFrom/dateTo for backend stats queries using getUTC* getters, so a
"this week" picked at 23:00 local on Sunday in CDT sends UTC-Monday-based
ranges to the backend. Fixing it properly also needs the backend to
filter on a tz-shifted UTC range, and Bambuddy has no user-tz setting
model today. localDateKey is in place for reuse when that work lands.
466 lines
14 KiB
TypeScript
466 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
|
|
* @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
|
|
): string {
|
|
const now = 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}`;
|
|
}
|