diff --git a/CHANGELOG.md b/CHANGELOG.md
index 936b2a3..dabfab1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
+### Improved
+
+- **European date and time formatting.** All dates across Pulse now use day-first ordering (14 Mar 2025) and 24-hour time (14:30) instead of the US-style month-first format. This applies everywhere — dashboard charts, exports, billing dates, invoices, uptime checks, audit logs, and more.
+
### Added
- **Free plan now visible on the Pricing page.** The free tier is no longer hidden — it's displayed as the first option on the Pricing page so you can see exactly what you get before signing up: 1 site, 5,000 monthly pageviews, and 6 months of data retention, completely free.
diff --git a/app/admin/orgs/[id]/page.tsx b/app/admin/orgs/[id]/page.tsx
index 3039798..3559ff9 100644
--- a/app/admin/orgs/[id]/page.tsx
+++ b/app/admin/orgs/[id]/page.tsx
@@ -4,13 +4,7 @@ import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { getAdminOrg, grantPlan, type AdminOrgDetail } from '@/lib/api/admin'
import { Button, LoadingOverlay, Select, toast } from '@ciphera-net/ui'
-
-function formatDate(d: Date) {
- return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
-}
-function formatDateTime(d: Date) {
- return d.toLocaleDateString('en-US', { dateStyle: 'long' }) + ' ' + d.toLocaleTimeString('en-US', { hour: 'numeric', minute: 'numeric' })
-}
+import { formatDate, formatDateTime } from '@/lib/utils/formatDate'
function addMonths(d: Date, months: number) {
const out = new Date(d)
out.setMonth(out.getMonth() + months)
diff --git a/app/admin/orgs/page.tsx b/app/admin/orgs/page.tsx
index 288107d..59747ec 100644
--- a/app/admin/orgs/page.tsx
+++ b/app/admin/orgs/page.tsx
@@ -4,10 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { listAdminOrgs, type AdminOrgSummary } from '@/lib/api/admin'
import { Button, LoadingOverlay, toast } from '@ciphera-net/ui'
-
-function formatDate(d: Date) {
- return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
-}
+import { formatDate } from '@/lib/utils/formatDate'
function CopyableOrgId({ id }: { id: string }) {
const [copied, setCopied] = useState(false)
diff --git a/app/page.tsx b/app/page.tsx
index 663b2c4..04dd558 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -17,6 +17,7 @@ import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } fr
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { getSitesLimitForPlan } from '@/lib/plans'
+import { formatDate } from '@/lib/utils/formatDate'
function DashboardPreview() {
return (
@@ -461,7 +462,7 @@ export default function HomePage() {
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)
: null
const amount = (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
style: 'currency',
diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx
index 8d0d1eb..de54aa6 100644
--- a/app/sites/[id]/settings/page.tsx
+++ b/app/sites/[id]/settings/page.tsx
@@ -7,6 +7,7 @@ import { createGoal, updateGoal, deleteGoal, type Goal } from '@/lib/api/goals'
import { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
+import { formatDateTime } from '@/lib/utils/formatDate'
import { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import VerificationModal from '@/components/sites/VerificationModal'
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
@@ -1341,7 +1342,7 @@ export default function SiteSettingsPage() {
Last sent: {schedule.last_sent_at
- ? new Date(schedule.last_sent_at).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric', hour: '2-digit', minute: '2-digit' })
+ ? formatDateTime(new Date(schedule.last_sent_at))
: 'Never'}
diff --git a/app/sites/[id]/uptime/page.tsx b/app/sites/[id]/uptime/page.tsx
index bbeb81a..497f95b 100644
--- a/app/sites/[id]/uptime/page.tsx
+++ b/app/sites/[id]/uptime/page.tsx
@@ -21,6 +21,7 @@ import { useTheme } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { Button, Modal } from '@ciphera-net/ui'
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
+import { formatDateFull, formatTime, formatDateTimeShort } from '@/lib/utils/formatDate'
import {
AreaChart,
Area,
@@ -165,11 +166,7 @@ function StatusBarTooltip({
}) {
if (!visible) return null
- const formattedDate = new Date(date + 'T00:00:00').toLocaleDateString('en-US', {
- weekday: 'short',
- month: 'short',
- day: 'numeric',
- })
+ const formattedDate = formatDateFull(new Date(date + 'T00:00:00'))
return (
c.response_time_ms !== null)
.map((c) => ({
- time: new Date(c.checked_at).toLocaleTimeString('en-US', {
- hour: '2-digit',
- minute: '2-digit',
- }),
+ time: formatTime(new Date(c.checked_at)),
ms: c.response_time_ms as number,
status: c.status,
}))
@@ -500,12 +494,7 @@ function MonitorCard({
- {new Date(check.checked_at).toLocaleString('en-US', {
- month: 'short',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- })}
+ {formatDateTimeShort(new Date(check.checked_at))}
diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx
index a685190..85ab438 100644
--- a/components/dashboard/Chart.tsx
+++ b/components/dashboard/Chart.tsx
@@ -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 = {
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 {
diff --git a/components/dashboard/ExportModal.tsx b/components/dashboard/ExportModal.tsx
index 184c2b1..39566d1 100644
--- a/components/dashboard/ExportModal.tsx
+++ b/components/dashboard/ExportModal.tsx
@@ -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 => {
export default function ExportModal({ isOpen, onClose, data, stats, topPages, topReferrers, campaigns }: ExportModalProps) {
const [format, setFormat] = useState('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>({
@@ -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)}%`
diff --git a/components/settings/OrganizationSettings.tsx b/components/settings/OrganizationSettings.tsx
index 1c1b4b0..2ee5bad 100644
--- a/components/settings/OrganizationSettings.tsx
+++ b/components/settings/OrganizationSettings.tsx
@@ -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'}
- Joined {new Date(member.joined_at).toLocaleDateString()}
+ Joined {formatDate(new Date(member.joined_at))}
@@ -813,7 +814,7 @@ export default function OrganizationSettings() {
{invite.email}
- Invited as {invite.role} • Expires {new Date(invite.expires_at).toLocaleDateString()}
+ Invited as {invite.role} • Expires {formatDate(new Date(invite.expires_at))}
@@ -861,7 +862,7 @@ export default function OrganizationSettings() {
{(() => {
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) : '—'
})()}
@@ -904,7 +905,7 @@ export default function OrganizationSettings() {
{(() => {
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) : '—'
})()}
@@ -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() })}
- {new Date(invoice.created * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
+ {formatDate(new Date(invoice.created * 1000))}
@@ -1282,7 +1283,7 @@ export default function OrganizationSettings() {
{entry.id}
- {new Date(entry.occurred_at).toLocaleString()}
+ {formatDateTime(new Date(entry.occurred_at))}
|
{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))}{' '}
(prorated)
) : (
diff --git a/components/settings/SecurityActivityCard.tsx b/components/settings/SecurityActivityCard.tsx
index 7e5ca6b..c0cd898 100644
--- a/components/settings/SecurityActivityCard.tsx
+++ b/components/settings/SecurityActivityCard.tsx
@@ -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() {
-
+
{formatRelativeTime(entry.created_at)}
diff --git a/components/settings/TrustedDevicesCard.tsx b/components/settings/TrustedDevicesCard.tsx
index 9441721..c551373 100644
--- a/components/settings/TrustedDevicesCard.tsx
+++ b/components/settings/TrustedDevicesCard.tsx
@@ -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() {
)}
-
+
First seen {formatRelativeTime(device.first_seen_at)}
·
-
+
Last seen {formatRelativeTime(device.last_seen_at)}
diff --git a/lib/utils/formatDate.ts b/lib/utils/formatDate.ts
index aa2cbaa..f59b0c5 100644
--- a/lib/utils/formatDate.ts
+++ b/lib/utils/formatDate.ts
@@ -1,3 +1,82 @@
+// Centralised date/time formatting for Pulse.
+// All functions use explicit European conventions:
+// • Day-first ordering (14 Mar 2025)
+// • 24-hour clock (14:30)
+// • en-GB locale for Intl consistency
+
+const LOCALE = 'en-GB'
+
+/** 14 Mar 2025 — tables, lists, general display */
+export function formatDate(d: Date): string {
+ return d.toLocaleDateString(LOCALE, { day: 'numeric', month: 'short', year: 'numeric' })
+}
+
+/** 14 Mar — charts, compact spaces. Adds year if different from current. */
+export function formatDateShort(d: Date): string {
+ const now = new Date()
+ return d.toLocaleDateString(LOCALE, {
+ day: 'numeric',
+ month: 'short',
+ year: d.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
+ })
+}
+
+/** 14 Mar 2025, 14:30 — logs, events, audit trails */
+export function formatDateTime(d: Date): string {
+ return d.toLocaleDateString(LOCALE, {
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })
+}
+
+/** 14:30 — intraday charts, time-only contexts */
+export function formatTime(d: Date): string {
+ return d.toLocaleTimeString(LOCALE, { hour: '2-digit', minute: '2-digit', hour12: false })
+}
+
+/** March 2025 — monthly aggregations */
+export function formatMonth(d: Date): string {
+ return d.toLocaleDateString(LOCALE, { month: 'long', year: 'numeric' })
+}
+
+/** 2025-03-14 — exports, filenames, API params */
+export function formatDateISO(d: Date): string {
+ return d.toISOString().split('T')[0]
+}
+
+/** Fri, 14 Mar 2025 — full date with weekday for tooltips */
+export function formatDateFull(d: Date): string {
+ return d.toLocaleDateString(LOCALE, {
+ weekday: 'short',
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ })
+}
+
+/** Fri, 14 Mar 2025, 14:30 — full date+time with weekday */
+export function formatDateTimeFull(d: Date): string {
+ return d.toLocaleDateString(LOCALE, {
+ weekday: 'short',
+ day: 'numeric',
+ month: 'short',
+ year: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ })
+}
+
+/** 14 March 2025 — long-form display (invoices, billing) */
+export function formatDateLong(d: Date): string {
+ return d.toLocaleDateString(LOCALE, { day: 'numeric', month: 'long', year: 'numeric' })
+}
+
+/** "Just now", "5m ago", "2h ago", "3d ago", then falls back to formatDateShort */
export function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr)
const now = new Date()
@@ -11,20 +90,16 @@ export function formatRelativeTime(dateStr: string): string {
if (diffHr < 24) return `${diffHr}h ago`
if (diffDay < 7) return `${diffDay}d ago`
- return date.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
- })
+ return formatDateShort(date)
}
-export function formatFullDate(dateStr: string): string {
- return new Date(dateStr).toLocaleString('en-US', {
- weekday: 'short',
- month: 'short',
+/** 14 Mar, 14:30 — compact date + time (uptime checks, recent activity) */
+export function formatDateTimeShort(d: Date): string {
+ return d.toLocaleDateString(LOCALE, {
day: 'numeric',
- year: 'numeric',
- hour: 'numeric',
+ month: 'short',
+ hour: '2-digit',
minute: '2-digit',
+ hour12: false,
})
}
diff --git a/lib/utils/notifications.tsx b/lib/utils/notifications.tsx
index b4441f1..28c9aff 100644
--- a/lib/utils/notifications.tsx
+++ b/lib/utils/notifications.tsx
@@ -1,20 +1,11 @@
import { AlertTriangleIcon, CheckCircleIcon } from '@ciphera-net/ui'
+import { formatRelativeTime } from './formatDate'
/**
* Formats a date string as a human-readable relative time (e.g. "5m ago", "2h ago").
*/
export function formatTimeAgo(dateStr: string): string {
- const d = new Date(dateStr)
- const now = new Date()
- const diffMs = now.getTime() - d.getTime()
- const diffMins = Math.floor(diffMs / 60000)
- const diffHours = Math.floor(diffMs / 3600000)
- const diffDays = Math.floor(diffMs / 86400000)
- if (diffMins < 1) return 'Just now'
- if (diffMins < 60) return `${diffMins}m ago`
- if (diffHours < 24) return `${diffHours}h ago`
- if (diffDays < 7) return `${diffDays}d ago`
- return d.toLocaleDateString()
+ return formatRelativeTime(dateStr)
}
/**
|