Localize calendar date pickers to the active UI locale (#234)

* 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>
This commit is contained in:
Paulo Castellano 2026-08-05 20:02:34 -04:00 committed by GitHub
parent 3baf2e9c41
commit 1af705f3fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 312 additions and 61 deletions

View file

@ -35,7 +35,10 @@
'empty' => 'لم يتم العثور على منطقة زمنية',
],
'just_now' => 'الآن',
'date_picker' => [
'label' => 'منتقي التاريخ',
'select' => 'اختر التاريخ',
],

View file

@ -35,7 +35,10 @@
'empty' => 'Keine Zeitzone gefunden',
],
'just_now' => 'Gerade eben',
'date_picker' => [
'label' => 'Datumsauswahl',
'select' => 'Datum auswählen',
],

View file

@ -35,7 +35,10 @@
'empty' => 'Δεν βρέθηκε ζώνη ώρας',
],
'just_now' => 'Μόλις τώρα',
'date_picker' => [
'label' => 'Επιλογή ημερομηνίας',
'select' => 'Επιλογή ημερομηνίας',
],

View file

@ -35,7 +35,10 @@
'empty' => 'No timezone found',
],
'just_now' => 'Just now',
'date_picker' => [
'label' => 'Date picker',
'select' => 'Select date',
],

View file

@ -35,7 +35,10 @@
'empty' => 'No se encontró zona horaria',
],
'just_now' => 'Justo ahora',
'date_picker' => [
'label' => 'Selector de fecha',
'select' => 'Seleccionar fecha',
],

View file

@ -35,7 +35,10 @@
'empty' => 'Aucun fuseau horaire trouvé',
],
'just_now' => 'À linstant',
'date_picker' => [
'label' => 'Sélecteur de date',
'select' => 'Sélectionner une date',
],

View file

@ -35,7 +35,10 @@
'empty' => 'Nessun fuso orario trovato',
],
'just_now' => 'Proprio ora',
'date_picker' => [
'label' => 'Selettore data',
'select' => 'Seleziona data',
],

View file

@ -35,7 +35,10 @@
'empty' => 'タイムゾーンが見つかりません',
],
'just_now' => 'たった今',
'date_picker' => [
'label' => '日付ピッカー',
'select' => '日付を選択',
],

View file

@ -35,7 +35,10 @@
'empty' => '시간대를 찾을 수 없습니다',
],
'just_now' => '방금',
'date_picker' => [
'label' => '날짜 선택기',
'select' => '날짜 선택',
],

View file

@ -35,7 +35,10 @@
'empty' => 'Geen tijdzone gevonden',
],
'just_now' => 'Zojuist',
'date_picker' => [
'label' => 'Datumperkiezer',
'select' => 'Datum selecteren',
],

View file

@ -35,7 +35,10 @@
'empty' => 'Nie znaleziono strefy czasowej',
],
'just_now' => 'Właśnie teraz',
'date_picker' => [
'label' => 'Wybór daty',
'select' => 'Wybierz datę',
],

View file

@ -35,7 +35,10 @@
'empty' => 'Fuso horário não encontrado',
],
'just_now' => 'Agora mesmo',
'date_picker' => [
'label' => 'Seletor de data',
'select' => 'Selecionar data',
],

View file

@ -35,7 +35,10 @@
'empty' => 'Часовой пояс не найден',
],
'just_now' => 'Только что',
'date_picker' => [
'label' => 'Выбор даты',
'select' => 'Выберите дату',
],

View file

@ -35,7 +35,10 @@
'empty' => 'Saat dilimi bulunamadı',
],
'just_now' => 'Az önce',
'date_picker' => [
'label' => 'Tarih seçici',
'select' => 'Tarih seç',
],

View file

@ -35,7 +35,10 @@
'empty' => 'Часовий пояс не знайдено',
],
'just_now' => 'Щойно',
'date_picker' => [
'label' => 'Вибір дати',
'select' => 'Виберіть дату',
],

View file

@ -35,7 +35,10 @@
'empty' => '未找到时区',
],
'just_now' => '刚刚',
'date_picker' => [
'label' => '日期选择器',
'select' => '选择日期',
],

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { parseDate } from '@internationalized/date';
import { IconCalendar } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { ref, watch, computed } from 'vue';
import { Button } from '@/components/ui/button';
@ -17,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useCalendarLocale } from '@/composables/useCalendarLocale';
import dayjs from '@/dayjs';
const props = defineProps({
@ -51,6 +53,8 @@ const emit = defineEmits<{
'update:modelValue': [value: string | null];
}>();
const calendarLocale = useCalendarLocale();
// Parse input value into date
const parseInput = (value: string) => {
if (!value) return undefined;
@ -156,7 +160,7 @@ watch(internalDate, (newDate) => {
}
});
// Display string for the button
// Display string for the button LL/LLL follow the active dayjs locale.
const displayText = computed(() => {
if (!props.modelValue || !dayjs(props.modelValue).isValid()) {
return null;
@ -165,10 +169,10 @@ const displayText = computed(() => {
const parsed = dayjs(props.modelValue);
if (props.showTime) {
return parsed.format('MMM D, YYYY [at] HH:mm');
return parsed.format('LLL');
}
return parsed.format('MMM D, YYYY');
return parsed.format('LL');
});
</script>
@ -182,8 +186,14 @@ const displayText = computed(() => {
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0" :align="align">
<Calendar v-model="internalDate as any" :placeholder="(internalDate as any)" layout="month-and-year" locale="en"
calendar-label="Date picker" initial-focus />
<Calendar
v-model="internalDate as any"
:placeholder="(internalDate as any)"
layout="month-and-year"
:locale="calendarLocale"
:calendar-label="trans('common.date_picker.label')"
initial-focus
/>
<!-- Time Picker -->
<div v-if="showTime" class="border-t-2 border-foreground/10 p-3">
<div class="flex items-center gap-2">
@ -212,4 +222,4 @@ const displayText = computed(() => {
</div>
</PopoverContent>
</Popover>
</template>
</template>

View file

@ -1,9 +1,8 @@
<script setup lang="ts">
import { CurveType } from '@unovis/ts';
import { VisArea, VisAxis, VisCrosshair, VisLine, VisTooltip, VisXYContainer } from '@unovis/vue';
import { getActiveLanguage } from 'laravel-vue-i18n';
import dayjs from '@/dayjs';
import date from '@/date';
type Point = { date: string; started: number; completed: number; failed: number };
@ -15,9 +14,6 @@ const seriesColors = {
failed: '#ef4444',
};
// English puts the month first (MM/DD); pt-BR and es read day first (DD/MM).
const dayMonthFormat = getActiveLanguage().toLowerCase().startsWith('en') ? 'MM/DD' : 'DD/MM';
const x = (_d: Point, i: number) => i;
const yStarted = (d: Point) => d.started;
const yCompleted = (d: Point) => d.completed;
@ -25,12 +21,12 @@ const yFailed = (d: Point) => d.failed;
const xTickFormat = (value: number): string => {
const point = props.data[Math.round(value)];
return point ? dayjs(point.date).format(dayMonthFormat) : '';
return point ? date.formatMonthDay(point.date) : '';
};
const tooltipTemplate = (d: Point): string =>
`<div style="font-size:12px;line-height:1.5">
<div style="font-weight:600;margin-bottom:2px">${dayjs(d.date).format(`${dayMonthFormat}/YYYY`)}</div>
<div style="font-weight:600;margin-bottom:2px">${date.formatMonthDayYear(d.date)}</div>
<div style="color:${seriesColors.started}"> started: ${d.started}</div>
<div style="color:${seriesColors.completed}"> completed: ${d.completed}</div>
<div style="color:${seriesColors.failed}"> failed: ${d.failed}</div>

View file

@ -1,11 +1,13 @@
<script setup lang="ts">
import { parseDate } from '@internationalized/date';
import { trans } from 'laravel-vue-i18n';
import { computed, ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useCalendarLocale } from '@/composables/useCalendarLocale';
import date from '@/date';
import dayjs from '@/dayjs';
@ -15,6 +17,7 @@ const props = defineProps<{
showRemove?: boolean;
}>();
const calendarLocale = useCalendarLocale();
const timezoneAbbr = date.getTimezoneAbbr();
const emit = defineEmits<{
@ -95,7 +98,13 @@ const remove = () => {
<DialogTitle class="sr-only">{{ $t('posts.edit.pick_time') }}</DialogTitle>
<div class="flex justify-center px-3 pt-3">
<Calendar v-model="internalDate as any" layout="month-and-year" locale="en" calendar-label="Pick a date" initial-focus />
<Calendar
v-model="internalDate as any"
layout="month-and-year"
:locale="calendarLocale"
:calendar-label="trans('common.date_picker.label')"
initial-focus
/>
</div>
<div class="border-t p-3">

View file

@ -107,7 +107,7 @@ const commentsByDay = computed((): DayGroup[] => {
? trans('comments.today')
: day === yesterday
? trans('comments.yesterday')
: dayjs(day).format('D MMM YYYY'),
: date.formatLocalDate(day),
comments: items,
}));
});

View file

@ -66,6 +66,7 @@ const props = defineProps<{
isReadOnly: boolean;
authUserId: string;
initialHighlightCommentId: string | null;
postedAt?: string | null;
}>();
const activeTab = defineModel<string>('activeTab', { required: true });
@ -104,6 +105,7 @@ defineExpose({
:media="media"
:platform-content-types="platformContentTypes"
:platform-meta="platformMeta"
:posted-at="postedAt"
/>
</TabsContent>

View file

@ -32,6 +32,7 @@ const props = defineProps<{
media: MediaItem[];
platformContentTypes: Record<string, string>;
platformMeta?: Record<string, Record<string, any>>;
postedAt?: string | null;
}>();
const getPlatformAvatar = (pp: PostPlatform): string | null => pp.social_account?.avatar_url ?? pp.platform_avatar ?? null;
@ -103,6 +104,7 @@ const activeContentType = computed((): string | undefined => {
:social-account="activePlatform.social_account"
:content-type="activeContentType"
:meta="platformMeta?.[activePlatform.id] ?? {}"
:posted-at="postedAt"
/>
</PhoneMockup>
<div v-else class="flex flex-col items-center gap-3 text-center">

View file

@ -1,10 +1,11 @@
<script setup lang="ts">
import { toRef } from 'vue';
import { computed, toRef } from 'vue';
import LinkCard from "@/components/posts/previews/LinkCard.vue";
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { useLinkCard } from '@/composables/useLinkCard';
import { isVideoMedia } from '@/composables/useMedia';
import date from '@/date';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
@ -19,10 +20,13 @@ interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
postedAt?: string | null;
}
const props = defineProps<Props>();
const postedAtLabel = computed(() => date.formatBlueskyPreview(props.postedAt));
const { card: linkCard, loading: linkCardLoading } = useLinkCard(
toRef(props, 'content'),
toRef(props, 'media'),
@ -117,7 +121,7 @@ const { card: linkCard, loading: linkCardLoading } = useLinkCard(
<!-- Timestamp -->
<div class="mt-3 text-[13px] text-neutral-500 dark:text-[#7b8d9e]">
4:18 PM · Jan 21, 2026
{{ postedAtLabel }}
</div>
</div>

View file

@ -1,8 +1,10 @@
<script setup lang="ts">
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import VideoPreview from '@/components/posts/previews/VideoPreview.vue';
import { isVideoMedia } from '@/composables/useMedia';
import date from '@/date';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
@ -31,11 +33,15 @@ const props = defineProps<{
content: string;
media: MediaItem[];
meta?: Record<string, any>;
postedAt?: string | null;
}>();
const embeds = computed<EmbedDraft[]>(() => (Array.isArray(props.meta?.embeds) ? (props.meta!.embeds as EmbedDraft[]) : []));
const channelName = computed<string>(() => (props.meta?.channel_name as string) || 'channel');
const mentions = computed<MentionChip[]>(() => (Array.isArray(props.meta?.mentions) ? (props.meta!.mentions as MentionChip[]) : []));
const postedAtLabel = computed(() =>
date.formatDiscordPreview(props.postedAt, trans('common.date_range_picker.today')),
);
</script>
<template>
@ -66,7 +72,7 @@ const mentions = computed<MentionChip[]>(() => (Array.isArray(props.meta?.mentio
<div class="flex items-baseline gap-2">
<span class="text-[15px] font-medium text-[#060607]">{{ socialAccount.display_name || 'TryPost' }}</span>
<span class="rounded bg-[#5865F2] px-1 text-[10px] font-bold uppercase tracking-wide text-white">Bot</span>
<span class="text-[11px] text-[#5c5e66]">Today at 4:30 PM</span>
<span class="text-[11px] text-[#5c5e66]">{{ postedAtLabel }}</span>
</div>
<p v-if="content" class="mt-0.5 whitespace-pre-wrap text-[15px] leading-[1.375] text-[#313338]">{{ content }}</p>

View file

@ -1,8 +1,10 @@
<script setup lang="ts">
import { IconDots, IconPhoto } from '@tabler/icons-vue';
import { trans } from 'laravel-vue-i18n';
import { computed } from 'vue';
import PostMediaPreview from '@/components/posts/previews/PostMediaPreview.vue';
import date from '@/date';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
@ -23,10 +25,15 @@ interface Props {
maxLength?: number;
isValid?: boolean;
validationMessage?: string;
postedAt?: string | null;
}
const props = defineProps<Props>();
const postedAtLabel = computed(() =>
date.formatFacebookPreview(props.postedAt, trans('common.just_now')),
);
// Content type helpers
const isReel = computed(() => props.contentType === 'facebook_reel');
const isStory = computed(() => props.contentType === 'facebook_story');
@ -116,7 +123,7 @@ const displayName = computed(() => props.socialAccount.display_name || props.soc
<div class="flex flex-col min-w-0">
<span class="text-[13px] font-semibold leading-tight">{{ displayName }}</span>
<div class="flex items-center gap-1 text-[11px] text-[#65676b] dark:text-[#b0b3b8]">
<span>Just now</span>
<span>{{ postedAtLabel }}</span>
<span>·</span>
<!-- Globe icon -->
<svg class="w-3 h-3" viewBox="0 0 16 16" fill="currentColor">

View file

@ -1,6 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue';
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { isVideoMedia } from '@/composables/useMedia';
import date from '@/date';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
@ -15,9 +18,12 @@ interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
postedAt?: string | null;
}
defineProps<Props>();
const props = defineProps<Props>();
const postedAtLabel = computed(() => date.formatMastodonPreview(props.postedAt));
</script>
<template>
@ -78,7 +84,7 @@ defineProps<Props>();
<!-- Timestamp -->
<div class="text-[14px] text-[#606984] dark:text-[#9baec8] mb-2">
<span>Jan 21, 2026, 04:30 PM</span>
<span>{{ postedAtLabel }}</span>
<span class="mx-1.5">·</span>
<svg class="inline h-4 w-4 align-text-bottom" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2">

View file

@ -31,6 +31,8 @@ interface Props {
media: MediaItem[];
contentType?: string;
meta?: Record<string, any>;
/** Local scheduled datetime (datetime-local); falls back to now in previews. */
postedAt?: string | null;
}
const props = defineProps<Props>();
@ -85,5 +87,6 @@ const previewComponent = computed(() => {
:media="media"
:content-type="contentType"
:meta="meta"
:posted-at="postedAt"
/>
</template>

View file

@ -5,6 +5,7 @@ import LinkCard from "@/components/posts/previews/LinkCard.vue";
import VideoPreview from "@/components/posts/previews/VideoPreview.vue";
import { useLinkCard } from '@/composables/useLinkCard';
import { isVideoMedia } from '@/composables/useMedia';
import date from '@/date';
import type { MediaItem } from '@/types/media';
interface SocialAccount {
@ -19,11 +20,13 @@ interface Props {
socialAccount: SocialAccount;
content: string;
media: MediaItem[];
postedAt?: string | null;
}
const props = defineProps<Props>();
const username = computed(() => props.socialAccount.username || 'username');
const postedAtLabel = computed(() => date.formatXPreview(props.postedAt));
const { card: linkCard, loading: linkCardLoading } = useLinkCard(
toRef(props, 'content'),
@ -127,7 +130,7 @@ const { card: linkCard, loading: linkCardLoading } = useLinkCard(
<!-- Timestamp & Views -->
<div class="mt-3 text-[15px] text-[#536471] dark:text-[#71767b]">
<span>4:21 PM · Jan 20, 2026</span>
<span>{{ postedAtLabel }}</span>
<span class="mx-1">·</span>
<span class="text-[#0f1419] dark:text-[#e7e9ea] font-bold">3.5M</span>
<span> Views</span>

View file

@ -16,6 +16,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover"
import { RangeCalendar } from "@/components/ui/range-calendar"
import { useCalendarLocale } from "@/composables/useCalendarLocale"
import { cn } from "@/lib/utils"
import dayjs from "@/dayjs"
@ -28,6 +29,8 @@ const emit = defineEmits<{
'update:modelValue': [value: { start: Date, end: Date }]
}>()
const calendarLocale = useCalendarLocale()
const toCalendarDate = (dateValue: Date) => {
return new CalendarDate(
dateValue.getFullYear(),
@ -127,11 +130,11 @@ watch(
>
<template v-if="value.start">
<template v-if="value.end">
{{ dayjs(toDate(value.start)).format('D MMM YYYY') }} -
{{ dayjs(toDate(value.end)).format('D MMM YYYY') }}
{{ dayjs(toDate(value.start)).format('LL') }} -
{{ dayjs(toDate(value.end)).format('LL') }}
</template>
<template v-else>
{{ dayjs(toDate(value.start)).format('D MMM YYYY') }}
{{ dayjs(toDate(value.start)).format('LL') }}
</template>
</template>
<template v-else>
@ -164,6 +167,7 @@ watch(
<RangeCalendar
v-model="value"
initial-focus
:locale="calendarLocale"
:number-of-months="numberOfMonths"
/>
</div>

View file

@ -0,0 +1,15 @@
import { usePage } from '@inertiajs/vue3';
import { computed, type ComputedRef } from 'vue';
/**
* Active UI locale for Reka Calendar / RangeCalendar.
*
* Mirrors the shared Inertia `locale` prop (same codes as config/languages.php
* and dayjs), so weekday headers and calendar a11y labels follow the user's
* language instead of the Reka English default.
*/
export const useCalendarLocale = (): ComputedRef<string> => {
const page = usePage();
return computed(() => (page.props.locale as string | undefined) || 'en');
};

View file

@ -8,17 +8,125 @@ 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');
},
formatDateTime(date: string) {
return dayjs
.utc(date)
.tz(getUserTimezone())
.format('D [de] MMM [de] YYYY [às] HH:mm');
/**
* 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) {
@ -112,7 +220,7 @@ export default {
* @returns String formatada (ex: "Fev/2025")
*/
formatMonthYear(month: number, year: number): string {
return dayjs(new Date(year, month - 1, 1)).format('MMM/YYYY');
return dayjs(new Date(year, month - 1, 1)).format('MMM YYYY');
},
formatAge(birthDate: string): string {
@ -145,7 +253,7 @@ export default {
* @returns String formatada (ex: "31/12/2025 14:25")
*/
formatBuildDate(date: string): string {
return dayjs.utc(date).tz(getUserTimezone()).format('DD/MM/YYYY HH:mm');
return dayjs.utc(date).tz(getUserTimezone()).format('L LT');
},
/**

View file

@ -17,7 +17,7 @@ import {
TableLoadMore,
TableRow,
} from '@/components/ui/table';
import dayjs from '@/dayjs';
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import {
metrics as metricsAutomation,
@ -46,7 +46,7 @@ const statusConfig = (status: string) => {
return configs[status] ?? configs['draft'];
};
const formatDate = (date: string) => dayjs.utc(date).local().format('D MMM YYYY');
const formatDate = (value: string) => date.formatDate(value);
const isCreating = ref(false);

View file

@ -20,7 +20,7 @@ import {
TableLoadMore,
TableRow,
} from '@/components/ui/table';
import dayjs from '@/dayjs';
import date from '@/date';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as labelsDestroy, index as labelsIndex } from '@/routes/app/labels';
@ -73,7 +73,7 @@ const handleDelete = (label: Label) => {
});
};
const formatDate = (date: string): string => dayjs.utc(date).local().format('D MMM YYYY');
const formatDate = (value: string): string => date.formatDate(value);
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
</script>

View file

@ -87,12 +87,12 @@ const weekdayNames = computed(() => {
return names;
});
const formatDayMonth = (day: dayjs.Dayjs): string => day.format('D MMMM');
// Day view computed
const currentDay = computed(() => dayjs(props.currentDay));
const dayHeaderTitle = computed(() => {
return currentDay.value.format('dddd, D [de] MMMM [de] YYYY');
});
const dayHeaderTitle = computed(() => currentDay.value.format('LL'));
const dayPosts = computed(() => {
const dateKey = currentDay.value.format('YYYY-MM-DD');
@ -117,18 +117,22 @@ const weekHeaderTitle = computed(() => {
const start = weekStart.value;
const end = weekStart.value.add(6, 'day');
if (start.month() === end.month()) {
return `${start.format('MMMM D')} - ${end.format('D, YYYY')}`;
// Day-first tokens so locales like pt-BR stay "39 de agosto", not "August 39".
if (start.isSame(end, 'month')) {
return `${start.format('D')}${end.format('D MMMM YYYY')}`;
}
return `${start.format('MMM D')} - ${end.format('MMM D, YYYY')}`;
if (start.isSame(end, 'year')) {
return `${start.format('D MMM')} ${end.format('D MMMM YYYY')}`;
}
return `${start.format('ll')} ${end.format('ll')}`;
});
// Month view computed
const monthDate = computed(() => dayjs(props.currentMonth));
const monthHeaderTitle = computed(() => {
return monthDate.value.format('MMMM YYYY');
});
const monthHeaderTitle = computed(() => monthDate.value.format('MMMM YYYY'));
const calendarDays = computed(() => {
const start = monthDate.value.startOf('month').startOf('week');
@ -392,7 +396,7 @@ const formatTime = (scheduledAt: string): string => {
class="mt-1 text-sm font-bold capitalize"
:class="isToday(day) ? 'text-foreground' : 'text-foreground/80'"
>
{{ day.format('D/MMMM') }}
{{ formatDayMonth(day) }}
</span>
</div>

View file

@ -21,7 +21,6 @@ import {
} from '@/composables/usePostCompliance';
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
import date from '@/date';
import dayjs from '@/dayjs';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as destroyPost, update as updatePost } from '@/routes/app/posts';
@ -152,13 +151,13 @@ const {
// Schedule
const scheduledDateTime = ref(date.formatUtcForDateTimeLocalInput(post.value.scheduled_at));
const hasPickedTime = ref(post.value.status === PostStatus.Scheduled && !! post.value.scheduled_at);
const hasPickedTime = ref(Boolean(post.value.scheduled_at));
const pickTimeLabel = computed(() => {
if (! hasPickedTime.value || ! scheduledDateTime.value) {
return trans('posts.edit.pick_time');
}
return dayjs(scheduledDateTime.value).format('MMM D, HH:mm');
return date.formatLocalDateTime(scheduledDateTime.value);
});
// Labels
@ -465,6 +464,7 @@ usePostEcho(post.value.id, '.post.comment.created', (e: any) => {
:is-read-only="isLocked"
:auth-user-id="authUserId"
:initial-highlight-comment-id="initialHighlightCommentId"
:posted-at="scheduledDateTime || null"
@toggle-platform="togglePlatform"
@toggle-label="toggleLabel"
@update:platform-meta="updatePlatformMeta"

View file

@ -34,7 +34,7 @@ import { useWorkspaceEcho } from '@/composables/echo/useWorkspaceEcho';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { getPostStatusConfig } from '@/composables/usePostStatus';
import { useWorkspaceRole } from '@/composables/useWorkspaceRole';
import dayjs from '@/dayjs';
import date from '@/date';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { copyToClipboard } from '@/lib/utils';
@ -128,9 +128,9 @@ const pageTitle = computed(() => {
return trans('posts.all_posts');
});
const formatDateTime = (date: string | null): string => {
if (!date) return '—';
return dayjs.utc(date).local().format('D MMM YYYY, HH:mm');
const formatDateTime = (value: string | null): string => {
if (!value) return '—';
return date.formatDateTime(value);
};
const getEnabledPlatforms = (post: Post) => post.post_platforms.filter((pp) => pp.enabled);

View file

@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { usePostEcho } from '@/composables/echo/usePostEcho';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import { getPlatformStatusConfig, getPostStatusConfig } from '@/composables/usePostStatus';
import dayjs from '@/dayjs';
import date from '@/date';
import AppLayout from '@/layouts/AppLayout.vue';
import { classify, isDocument as isDocumentItem, isVideo as isVideoItem, MediaType } from '@/lib/mediaType';
import { index as postsIndex } from '@/routes/app/posts';
@ -83,8 +83,8 @@ const getDisplayUsername = (pp: PostPlatform): string | null => pp.display_usern
const getDisplayAvatar = (pp: PostPlatform): string | null => pp.display_avatar;
const formatDateTime = (date: string | null): string =>
date ? dayjs.utc(date).local().format('D MMM YYYY, HH:mm') : '';
const formatDateTime = (value: string | null): string =>
value ? date.formatDateTime(value) : '';
const lightbox = ref<InstanceType<typeof ImagePreviewDialog> | null>(null);

View file

@ -120,7 +120,7 @@ const tabs = computed(() => [
<TableRow v-for="token in apiTokens" :key="token.id">
<TableCell>{{ token.name }}</TableCell>
<TableCell>
{{ token.expires_at ? date.formatDate(token.expires_at) : $t('settings.api_keys.table.never') }}
{{ token.expires_at ? date.formatDateOnly(token.expires_at) : $t('settings.api_keys.table.never') }}
</TableCell>
<TableCell>
{{ token.last_used_at ? date.diffForHumans(token.last_used_at) : $t('settings.api_keys.table.never') }}

View file

@ -20,7 +20,7 @@ import {
TableLoadMore,
TableRow,
} from '@/components/ui/table';
import dayjs from '@/dayjs';
import date from '@/date';
import debounce from '@/debounce';
import AppLayout from '@/layouts/AppLayout.vue';
import { destroy as signaturesDestroy, index as signaturesIndex } from '@/routes/app/signatures';
@ -79,7 +79,7 @@ const handleDelete = (signature: Signature) => {
});
};
const formatDate = (date: string): string => dayjs.utc(date).local().format('D MMM YYYY');
const formatDate = (value: string): string => date.formatDate(value);
const hasActiveSearch = computed(() => Boolean(searchQuery.value?.trim()));
</script>

View file

@ -54,10 +54,12 @@ function makeWorkspaceToken(User $user, Workspace $workspace): AccessToken
});
it('creates an api key with expiration', function () {
$expiresOn = now()->addDays(30)->format('Y-m-d');
$this->actingAs($this->user)
->post(route('app.api-keys.store'), [
'name' => 'Expiring Key',
'expires_at' => now()->addDays(30)->format('Y-m-d'),
'expires_at' => $expiresOn,
])
->assertRedirect();
@ -65,7 +67,29 @@ function makeWorkspaceToken(User $user, Workspace $workspace): AccessToken
->where('workspace_id', $this->workspace->id)
->first();
expect($token->expires_at)->not->toBeNull();
expect($token->expires_at)->not->toBeNull()
->and($token->expires_at->toDateString())->toBe($expiresOn);
});
it('passes the chosen expiry calendar day to the api keys page', function () {
$expiresOn = '2026-10-31';
$result = $this->user->createToken('Expiring Key');
$token = AccessToken::find($result->token->id);
$token->forceFill([
'workspace_id' => $this->workspace->id,
'expires_at' => $expiresOn,
])->saveQuietly();
$this->actingAs($this->user)
->get(route('app.api-keys.index'))
->assertOk()
->assertInertia(fn ($page) => $page
->component('settings/workspace/ApiKeys')
->has('apiTokens', 1)
->where('apiTokens.0.name', 'Expiring Key')
->where('apiTokens.0.expires_at', fn ($value) => is_string($value) && str_starts_with($value, $expiresOn))
);
});
it('validates name is required', function () {