* Localize calendar date pickers to the active UI locale. Pass the Inertia locale into Reka calendars, translate a11y labels, and use dayjs LL/LLL for DatePicker display text. Co-authored-by: Cursor <cursoragent@cursor.com> * Localize remaining dayjs date displays across the UI. Route list/calendar/preview timestamps through localized LL/LLL helpers so formats follow the active UI locale instead of English or Portuguese-locked patterns. Co-authored-by: Cursor <cursoragent@cursor.com> * Use scheduled-or-now timestamps in platform previews. Drive preview labels from the post schedule when set, localize Discord without dayjs calendar(), and expose a single formatPreviewPostedAt helper. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix preview schedule wiring and Discord/Facebook timestamp labels. Restore hasPickedTime from any saved scheduled_at, keep time on non-today Discord labels, and use a localized just-now fallback for unschedled Facebook previews. Co-authored-by: Cursor <cursoragent@cursor.com> * Fix API key expiry day shift and tighten calendar range titles. Format date-only expiry in UTC calendar days, and use compact dayjs titles for calendar day/week headers. Co-authored-by: Cursor <cursoragent@cursor.com> * Replace frontend date source greps with Inertia API key assertions. Drop the Vue/TS file scanner and assert expiry calendar days through ApiKeyController props instead. Co-authored-by: Cursor <cursoragent@cursor.com> * Localize calendar week titles with day-first dayjs formats. Avoid English month-day order in week headers and chart axis labels so locales like pt-BR keep natural day-first dates. Co-authored-by: Cursor <cursoragent@cursor.com> * Split preview timestamp formatting into named platform helpers. Replace the style-switch formatPreviewPostedAt with clear per-platform helpers so call sites read as formatDiscordPreview / formatXPreview / etc. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
371 lines
11 KiB
TypeScript
371 lines
11 KiB
TypeScript
import dayjs from '@/dayjs';
|
|
|
|
/**
|
|
* Obtém o timezone do usuário
|
|
* Tenta pegar do Inertia page props primeiro, senão usa o timezone do browser
|
|
*/
|
|
function getUserTimezone(): string {
|
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
}
|
|
|
|
/** Resolve scheduled local datetime for platform previews, else now. */
|
|
const resolvePreviewPostedAt = (postedAt?: string | null) => {
|
|
if (postedAt) {
|
|
const parsed = dayjs(postedAt);
|
|
if (parsed.isValid()) {
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
return dayjs();
|
|
};
|
|
|
|
/** X / Bluesky style: `4:21 PM · Aug 5, 2026` (locale-aware). */
|
|
const formatAbsolutePreviewPostedAt = (postedAt?: string | null) => {
|
|
const instant = resolvePreviewPostedAt(postedAt);
|
|
|
|
return `${instant.format('LT')} · ${instant.format('ll')}`;
|
|
};
|
|
|
|
export default {
|
|
formatDate(date: string | null | undefined) {
|
|
if (!date) return '-';
|
|
return dayjs.utc(date).tz(getUserTimezone()).format('LL');
|
|
},
|
|
|
|
/**
|
|
* Format a calendar date (expiry, due date, etc.) without shifting the day
|
|
* across timezones. Use for values stored as UTC midnight that represent a
|
|
* chosen calendar day rather than an exact instant.
|
|
*/
|
|
formatDateOnly(date: string | null | undefined) {
|
|
if (!date) {
|
|
return '-';
|
|
}
|
|
|
|
return dayjs.utc(date).format('LL');
|
|
},
|
|
|
|
formatDateTime(date: string | null | undefined) {
|
|
if (!date) {
|
|
return '—';
|
|
}
|
|
|
|
return dayjs.utc(date).tz(getUserTimezone()).format('LLL');
|
|
},
|
|
|
|
/**
|
|
* Format a local (already timezone-converted) datetime string.
|
|
* Use for datetime-local values and other non-UTC inputs.
|
|
*/
|
|
formatLocalDateTime(date: string | null | undefined) {
|
|
if (!date) {
|
|
return '—';
|
|
}
|
|
|
|
return dayjs(date).format('lll');
|
|
},
|
|
|
|
/**
|
|
* Format a local date-only value (YYYY-MM-DD or Date) with the active locale.
|
|
*/
|
|
formatLocalDate(date: string | Date | null | undefined) {
|
|
if (!date) {
|
|
return '—';
|
|
}
|
|
|
|
return dayjs(date).format('LL');
|
|
},
|
|
|
|
/**
|
|
* Short day + month for chart axes (day-first so locales keep natural order).
|
|
*/
|
|
formatMonthDay(date: string | Date) {
|
|
return dayjs(date).format('D MMM');
|
|
},
|
|
|
|
/**
|
|
* Short month + day + year for chart tooltips (locale-aware via L).
|
|
*/
|
|
formatMonthDayYear(date: string | Date) {
|
|
return dayjs(date).format('L');
|
|
},
|
|
|
|
formatXPreview(postedAt?: string | null) {
|
|
return formatAbsolutePreviewPostedAt(postedAt);
|
|
},
|
|
|
|
formatBlueskyPreview(postedAt?: string | null) {
|
|
return formatAbsolutePreviewPostedAt(postedAt);
|
|
},
|
|
|
|
formatMastodonPreview(postedAt?: string | null) {
|
|
return resolvePreviewPostedAt(postedAt).format('lll');
|
|
},
|
|
|
|
/**
|
|
* @param justNowLabel Localized fallback when no schedule is set (e.g. common.just_now).
|
|
*/
|
|
formatFacebookPreview(postedAt?: string | null, justNowLabel?: string) {
|
|
if (! postedAt && justNowLabel) {
|
|
return justNowLabel;
|
|
}
|
|
|
|
return resolvePreviewPostedAt(postedAt).fromNow();
|
|
},
|
|
|
|
/**
|
|
* @param todayLabel Localized same-day prefix (e.g. common.date_range_picker.today).
|
|
*/
|
|
formatDiscordPreview(postedAt?: string | null, todayLabel?: string) {
|
|
const instant = resolvePreviewPostedAt(postedAt);
|
|
|
|
if (instant.isSame(dayjs(), 'day')) {
|
|
return todayLabel
|
|
? `${todayLabel} · ${instant.format('LT')}`
|
|
: instant.format('LT');
|
|
}
|
|
|
|
return instant.format('lll');
|
|
},
|
|
|
|
formatTime(date: string | null | undefined) {
|
|
if (!date) return '-';
|
|
return dayjs.utc(date).tz(getUserTimezone()).format('HH:mm');
|
|
},
|
|
|
|
formatDateTimeForApi(date: string) {
|
|
// Convert from user timezone to UTC for API
|
|
return dayjs
|
|
.tz(date, getUserTimezone())
|
|
.utc()
|
|
.format('YYYY-MM-DD HH:mm:ss');
|
|
},
|
|
|
|
formatDateTimeForDatePicker(date: string) {
|
|
// Convert from UTC to user timezone for display
|
|
if (!date) return dayjs().tz(getUserTimezone());
|
|
return dayjs.utc(date).tz(getUserTimezone());
|
|
},
|
|
|
|
diffForHumans(date: string) {
|
|
const localDate = dayjs
|
|
.utc(date)
|
|
.tz(getUserTimezone())
|
|
.format('YYYY-MM-DD HH:mm:ss');
|
|
return dayjs().to(dayjs(localDate));
|
|
},
|
|
|
|
formatTimelineDate(dateStr: string) {
|
|
const d = dayjs(dateStr);
|
|
return {
|
|
day: d.date(),
|
|
month: d.format('MMM'),
|
|
year: d.year(),
|
|
};
|
|
},
|
|
|
|
formatDuration(duration: string) {
|
|
// Duration comes in format "HH:mm:ss"
|
|
const [hours, minutes] = duration.split(':');
|
|
const h = parseInt(hours, 10);
|
|
const m = parseInt(minutes, 10);
|
|
|
|
if (h > 0) {
|
|
return `${h}h ${m}min`;
|
|
}
|
|
return `${m}min`;
|
|
},
|
|
|
|
formatMedicalRecordDuration(startAt: string, duration: string | null) {
|
|
const startTime = this.formatTime(startAt);
|
|
|
|
if (!duration) {
|
|
return startTime;
|
|
}
|
|
|
|
const formattedDuration = this.formatDuration(duration);
|
|
return `${startTime} (${formattedDuration})`;
|
|
},
|
|
|
|
/**
|
|
* Converte horário de appointment de UTC para timezone do usuário
|
|
* Específico para agenda onde date e time vêm separados do banco
|
|
* @param date - Data no formato YYYY-MM-DD
|
|
* @param time - Horário no formato HH:mm:ss
|
|
* @returns Horário formatado no timezone do usuário (HH:mm)
|
|
*/
|
|
convertAppointmentTimeToUserTimezone(date: string, time: string): string {
|
|
return dayjs
|
|
.utc(`${date} ${time}`)
|
|
.tz(getUserTimezone())
|
|
.format('HH:mm');
|
|
},
|
|
|
|
/**
|
|
* Converte horário de appointment de UTC para timezone do usuário e retorna objeto dayjs
|
|
* Útil para cálculos de posicionamento na agenda
|
|
* @param date - Data no formato YYYY-MM-DD
|
|
* @param time - Horário no formato HH:mm:ss
|
|
* @returns Objeto dayjs no timezone do usuário
|
|
*/
|
|
getAppointmentDateTimeInUserTimezone(date: string, time: string) {
|
|
return dayjs.utc(`${date} ${time}`).tz(getUserTimezone());
|
|
},
|
|
|
|
/**
|
|
* Formata o nome do mês e ano
|
|
* @param month - Número do mês (1-12)
|
|
* @param year - Ano (ex: 2025)
|
|
* @returns String formatada (ex: "Fev/2025")
|
|
*/
|
|
formatMonthYear(month: number, year: number): string {
|
|
return dayjs(new Date(year, month - 1, 1)).format('MMM YYYY');
|
|
},
|
|
|
|
formatAge(birthDate: string): string {
|
|
return dayjs().from(dayjs(birthDate), true);
|
|
},
|
|
|
|
/**
|
|
* Formata tempo decorrido em segundos para formato de stopwatch HH:MM:SS
|
|
* @param seconds - Tempo decorrido em segundos
|
|
* @returns String formatada (ex: "02:30:45", "00:05:12")
|
|
*/
|
|
formatStopwatch(seconds: number): string {
|
|
return dayjs.duration(seconds, 'seconds').format('HH:mm:ss');
|
|
},
|
|
|
|
/**
|
|
* Calcula tempo decorrido entre uma data UTC e o momento atual no timezone do usuário
|
|
* @param startDateTime - Data/hora de início em UTC (ISO string)
|
|
* @returns Tempo decorrido em segundos
|
|
*/
|
|
getElapsedSeconds(startDateTime: string): number {
|
|
const startTime = dayjs.utc(startDateTime).tz(getUserTimezone());
|
|
const now = dayjs().tz(getUserTimezone());
|
|
return now.diff(startTime, 'seconds');
|
|
},
|
|
|
|
/**
|
|
* Formata a data de build da aplicação no timezone do usuário
|
|
* @param date - Data/hora em ISO string (UTC)
|
|
* @returns String formatada (ex: "31/12/2025 14:25")
|
|
*/
|
|
formatBuildDate(date: string): string {
|
|
return dayjs.utc(date).tz(getUserTimezone()).format('L LT');
|
|
},
|
|
|
|
/**
|
|
* Obtém o timezone do usuário
|
|
* Tenta pegar do Inertia page props primeiro, senão usa o timezone do browser
|
|
* @returns Timezone do usuário
|
|
*/
|
|
getUserTimezone,
|
|
|
|
/**
|
|
* Abreviação do timezone do usuário para exibição (ex.: "GMT-3")
|
|
* @returns Abreviação do timezone
|
|
*/
|
|
getTimezoneAbbr(): string {
|
|
return dayjs().format('z');
|
|
},
|
|
|
|
/**
|
|
* Formata uma data para o formato YYYY-MM-DD (usado em DatePicker)
|
|
* Evita problemas de timezone ao não criar objeto Date
|
|
* @param date - Data no formato YYYY-MM-DD ou ISO string
|
|
* @returns Data no formato YYYY-MM-DD
|
|
*/
|
|
formatDateForInput(date: string): string {
|
|
return dayjs(date).format('YYYY-MM-DD');
|
|
},
|
|
|
|
/**
|
|
* Converte uma data UTC para o formato esperado por inputs HTML `datetime-local`
|
|
* (YYYY-MM-DDTHH:mm:00) no timezone do usuário.
|
|
* @param date - Data em UTC (ISO string) ou nulo
|
|
* @returns String no formato YYYY-MM-DDTHH:mm:00 ou string vazia quando não houver data
|
|
*/
|
|
formatUtcForDateTimeLocalInput(date: string | null | undefined): string {
|
|
if (!date) return '';
|
|
return dayjs.utc(date).tz(getUserTimezone()).format('YYYY-MM-DDTHH:mm:00');
|
|
},
|
|
|
|
/**
|
|
* Inverso de `formatUtcForDateTimeLocalInput`: recebe uma string vinda de um
|
|
* input `datetime-local` (no timezone do usuário) e devolve uma ISO 8601 em
|
|
* UTC pronta para enviar à API. Retorna `null` quando não houver data.
|
|
* @param date - String do input (YYYY-MM-DDTHH:mm[:ss]) ou nulo
|
|
* @returns ISO 8601 em UTC (ex.: 2026-05-19T19:40:00Z) ou null
|
|
*/
|
|
formatLocalDateTimeForApi(date: string | null | undefined): string | null {
|
|
if (!date) return null;
|
|
return dayjs.tz(date, getUserTimezone()).utc().format();
|
|
},
|
|
|
|
/**
|
|
* Formata minutos para formato legível
|
|
* @param minutes - Número de minutos
|
|
* @returns String formatada (ex: "1h", "1h 41min", "30min")
|
|
*/
|
|
formatMinutes(minutes: number): string {
|
|
const duration = dayjs.duration(minutes, 'minutes');
|
|
const h = duration.hours();
|
|
const m = duration.minutes();
|
|
|
|
if (h > 0 && m > 0) {
|
|
return `${h}h ${m}min`;
|
|
}
|
|
if (h > 0) {
|
|
return `${h}h`;
|
|
}
|
|
return `${m}min`;
|
|
},
|
|
|
|
/**
|
|
* Format seconds as a clock (m:ss, or h:mm:ss past one hour).
|
|
* For media badges and stopwatches (e.g. "5:30", "1:05:30").
|
|
*/
|
|
formatClock(seconds: number): string {
|
|
const d = dayjs.duration(Math.round(seconds), 'seconds');
|
|
|
|
return d.asHours() >= 1 ? d.format('H:mm:ss') : d.format('m:ss');
|
|
},
|
|
|
|
/**
|
|
* Format seconds as short words (e.g. "45s", "5min", "5min 30s").
|
|
*/
|
|
formatDurationWords(seconds: number): string {
|
|
const s = Math.round(seconds);
|
|
if (s < 60) {
|
|
return `${s}s`;
|
|
}
|
|
|
|
const m = Math.floor(s / 60);
|
|
const rem = s % 60;
|
|
|
|
return rem === 0 ? `${m}min` : `${m}min ${rem}s`;
|
|
},
|
|
|
|
/**
|
|
* Format a duration in milliseconds (e.g. "—", "500ms", "1.5s", "2m 30s").
|
|
*/
|
|
formatDurationMs(ms: number | null | undefined): string {
|
|
if (ms == null) {
|
|
return '—';
|
|
}
|
|
if (ms < 1000) {
|
|
return `${Math.round(ms)}ms`;
|
|
}
|
|
|
|
const seconds = ms / 1000;
|
|
if (seconds < 60) {
|
|
return `${seconds.toFixed(1)}s`;
|
|
}
|
|
|
|
const minutes = Math.floor(seconds / 60);
|
|
|
|
return `${minutes}m ${Math.round(seconds % 60)}s`;
|
|
},
|
|
};
|