feat: centralise date/time formatting with European conventions
All dates now use day-first ordering (14 Mar 2025) and 24-hour time (14:30) via a single formatDate.ts module, replacing scattered inline toLocaleDateString/toLocaleTimeString calls across 12 files.
This commit is contained in:
@@ -11,6 +11,7 @@ import { Checkbox } from '@ciphera-net/ui'
|
||||
import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { formatTime, formatDateShort, formatDate } from '@/lib/utils/formatDate'
|
||||
|
||||
const ANNOTATION_COLORS: Record<string, string> = {
|
||||
deploy: '#3b82f6',
|
||||
@@ -84,8 +85,7 @@ type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration'
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function formatEU(dateStr: string): string {
|
||||
const [y, m, d] = dateStr.split('-')
|
||||
return `${d}/${m}/${y}`
|
||||
return formatDate(new Date(dateStr + 'T00:00:00'))
|
||||
}
|
||||
|
||||
// ─── Metric configurations ──────────────────────────────────────────
|
||||
@@ -216,15 +216,12 @@ export default function Chart({
|
||||
const chartData = useMemo(() => data.map((item) => {
|
||||
let formattedDate: string
|
||||
if (interval === 'minute') {
|
||||
formattedDate = new Date(item.date).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||
formattedDate = formatTime(new Date(item.date))
|
||||
} else if (interval === 'hour') {
|
||||
const d = new Date(item.date)
|
||||
const isMidnight = d.getHours() === 0 && d.getMinutes() === 0
|
||||
formattedDate = isMidnight
|
||||
? d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' 12:00 AM'
|
||||
: d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })
|
||||
formattedDate = formatDateShort(d) + ', ' + formatTime(d)
|
||||
} else {
|
||||
formattedDate = new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
formattedDate = formatDateShort(new Date(item.date))
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import jsPDF from 'jspdf'
|
||||
import autoTable from 'jspdf-autotable'
|
||||
import type { DailyStat } from './Chart'
|
||||
import { formatNumber, formatDuration } from '@ciphera-net/ui'
|
||||
import { formatDateISO, formatDate, formatDateTime } from '@/lib/utils/formatDate'
|
||||
import { getReferrerDisplayName, mergeReferrersByDisplayName } from '@/lib/utils/icons'
|
||||
import type { TopPage, TopReferrer, CampaignStat } from '@/lib/api/stats'
|
||||
|
||||
@@ -47,7 +48,7 @@ const loadImage = (src: string): Promise<string> => {
|
||||
|
||||
export default function ExportModal({ isOpen, onClose, data, stats, topPages, topReferrers, campaigns }: ExportModalProps) {
|
||||
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||
const [filename, setFilename] = useState(`pulse_export_${new Date().toISOString().split('T')[0]}`)
|
||||
const [filename, setFilename] = useState(`pulse_export_${formatDateISO(new Date())}`)
|
||||
const [includeHeader, setIncludeHeader] = useState(true)
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({
|
||||
@@ -153,9 +154,9 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
||||
// Metadata (Top Right)
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor(150, 150, 150)
|
||||
const generatedDate = new Date().toLocaleDateString()
|
||||
const generatedDate = formatDate(new Date())
|
||||
const dateRange = data.length > 0
|
||||
? `${new Date(data[0].date).toLocaleDateString()} - ${new Date(data[data.length - 1].date).toLocaleDateString()}`
|
||||
? `${formatDate(new Date(data[0].date))} - ${formatDate(new Date(data[data.length - 1].date))}`
|
||||
: generatedDate
|
||||
|
||||
const pageWidth = doc.internal.pageSize.width
|
||||
@@ -202,9 +203,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
||||
const val = row[field]
|
||||
if (field === 'date' && typeof val === 'string') {
|
||||
const date = new Date(val)
|
||||
return isHourly
|
||||
? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
: date.toLocaleDateString()
|
||||
return isHourly ? formatDateTime(date) : formatDate(date)
|
||||
}
|
||||
if (typeof val === 'number') {
|
||||
if (field === 'bounce_rate') return `${Math.round(val)}%`
|
||||
|
||||
@@ -25,6 +25,7 @@ import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
|
||||
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||
import { formatDate, formatDateTime, formatDateLong } from '@/lib/utils/formatDate'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import {
|
||||
AlertTriangleIcon,
|
||||
@@ -776,7 +777,7 @@ export default function OrganizationSettings() {
|
||||
{member.user_email || 'Unknown User'}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Joined {new Date(member.joined_at).toLocaleDateString()}
|
||||
Joined {formatDate(new Date(member.joined_at))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -813,7 +814,7 @@ export default function OrganizationSettings() {
|
||||
{invite.email}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
Invited as <span className="capitalize font-medium">{invite.role}</span> • Expires {new Date(invite.expires_at).toLocaleDateString()}
|
||||
Invited as <span className="capitalize font-medium">{invite.role}</span> • Expires {formatDate(new Date(invite.expires_at))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -861,7 +862,7 @@ export default function OrganizationSettings() {
|
||||
<span className="font-semibold">
|
||||
{(() => {
|
||||
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null
|
||||
return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }) : '—'
|
||||
return d && !Number.isNaN(d.getTime()) ? formatDateLong(d) : '—'
|
||||
})()}
|
||||
</span>
|
||||
</p>
|
||||
@@ -904,7 +905,7 @@ export default function OrganizationSettings() {
|
||||
<span className="font-semibold">
|
||||
{(() => {
|
||||
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null
|
||||
return d && !Number.isNaN(d.getTime()) ? d.toLocaleDateString(undefined, { month: 'long', day: 'numeric', year: 'numeric' }) : '—'
|
||||
return d && !Number.isNaN(d.getTime()) ? formatDateLong(d) : '—'
|
||||
})()}
|
||||
</span>
|
||||
</p>
|
||||
@@ -1016,7 +1017,7 @@ export default function OrganizationSettings() {
|
||||
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
|
||||
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
|
||||
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
||||
? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
? formatDate(d)
|
||||
: '—'
|
||||
const amount = subscription.next_invoice_amount_due != null && subscription.next_invoice_currency
|
||||
? (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
|
||||
@@ -1079,7 +1080,7 @@ export default function OrganizationSettings() {
|
||||
{(invoice.amount_paid / 100).toLocaleString('en-US', { style: 'currency', currency: invoice.currency.toUpperCase() })}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-500 ml-2">
|
||||
{new Date(invoice.created * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
{formatDate(new Date(invoice.created * 1000))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1282,7 +1283,7 @@ export default function OrganizationSettings() {
|
||||
{entry.id}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-400 whitespace-nowrap">
|
||||
{new Date(entry.occurred_at).toLocaleString()}
|
||||
{formatDateTime(new Date(entry.occurred_at))}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-neutral-900 dark:text-white whitespace-nowrap" title={entry.actor_email || entry.actor_id || 'System'}>
|
||||
{entry.actor_email || entry.actor_id || 'System'}
|
||||
@@ -1606,7 +1607,7 @@ export default function OrganizationSettings() {
|
||||
style: 'currency',
|
||||
currency: invoicePreview.currency.toUpperCase(),
|
||||
})}{' '}
|
||||
on {new Date(invoicePreview.period_end * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}{' '}
|
||||
on {formatDate(new Date(invoicePreview.period_end * 1000))}{' '}
|
||||
<span className="text-neutral-500">(prorated)</span>
|
||||
</p>
|
||||
) : (
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { getUserActivity, type AuditLogEntry } from '@/lib/api/activity'
|
||||
import { Spinner } from '@ciphera-net/ui'
|
||||
import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate'
|
||||
import { formatRelativeTime, formatDateTimeFull } from '@/lib/utils/formatDate'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
@@ -189,7 +189,7 @@ export default function SecurityActivityCard() {
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0 text-right">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400" title={formatFullDate(entry.created_at)}>
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400" title={formatDateTimeFull(new Date(entry.created_at))}>
|
||||
{formatRelativeTime(entry.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { getUserDevices, removeDevice, type TrustedDevice } from '@/lib/api/devices'
|
||||
import { Spinner, toast } from '@ciphera-net/ui'
|
||||
import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate'
|
||||
import { formatRelativeTime, formatDateTimeFull } from '@/lib/utils/formatDate'
|
||||
|
||||
function getDeviceIcon(hint: string): string {
|
||||
const h = hint.toLowerCase()
|
||||
@@ -101,11 +101,11 @@ export default function TrustedDevicesCard() {
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<span title={formatFullDate(device.first_seen_at)}>
|
||||
<span title={formatDateTimeFull(new Date(device.first_seen_at))}>
|
||||
First seen {formatRelativeTime(device.first_seen_at)}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span title={formatFullDate(device.last_seen_at)}>
|
||||
<span title={formatDateTimeFull(new Date(device.last_seen_at))}>
|
||||
Last seen {formatRelativeTime(device.last_seen_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user