From 7ba5e063ca2ddde1a53bf1da54527d5c265834df Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Fri, 13 Mar 2026 21:28:04 +0100 Subject: [PATCH 1/5] feat: add free plan to pricing page and enforce 1-site limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the free tier (€0, 1 site, 5k pageviews, 6 months retention) as the first card on the pricing page. Enforce a 1-site limit for free plan users in the frontend. --- CHANGELOG.md | 5 +++++ components/PricingSection.tsx | 37 ++++++++++++++++++++++++++++++++++- lib/plans.ts | 4 ++-- 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d9df91f..936b2a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### 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. +- **Free plan limited to 1 site.** Free accounts are now limited to a single site. If you need more, you can upgrade to Solo or above from the Pricing page. + ### Improved - **Sites now show their verification status.** Each site on your dashboard now displays either a green "Active" badge (if verified) or an amber "Unverified" badge. When you verify your tracking script installation, the status is saved permanently — no more showing "Active" for sites that haven't been set up yet. diff --git a/components/PricingSection.tsx b/components/PricingSection.tsx index 048f942..8205816 100644 --- a/components/PricingSection.tsx +++ b/components/PricingSection.tsx @@ -292,7 +292,42 @@ export default function PricingSection() { {/* Pricing Grid */} -
+
+ {/* Free Plan */} +
+
+

Free

+

For trying Pulse on a personal project

+
+ €0 + /forever +
+
+ + + +
    + {['1 site', '5k monthly pageviews', '6 months data retention', '100% Data ownership'].map((feature) => ( +
  • + + {feature} +
  • + ))} +
+
+ {PLANS.map((plan) => { const priceDetails = getPriceDetails(plan.id) const isTeam = plan.id === 'team' diff --git a/lib/plans.ts b/lib/plans.ts index 5456837..a2d2ce9 100644 --- a/lib/plans.ts +++ b/lib/plans.ts @@ -7,9 +7,9 @@ export const PLAN_ID_SOLO = 'solo' export const PLAN_ID_TEAM = 'team' export const PLAN_ID_BUSINESS = 'business' -/** Sites limit per plan. Returns null for free (no limit enforced in UI). */ +/** Sites limit per plan. */ export function getSitesLimitForPlan(planId: string | null | undefined): number | null { - if (!planId || planId === 'free') return null + if (!planId || planId === 'free') return 1 switch (planId) { case 'solo': return 1 case 'team': return 5 From 25210013d39ea892ff5e2e4f9cd15f71182f53f4 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 13:31:30 +0100 Subject: [PATCH 2/5] 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. --- CHANGELOG.md | 4 + app/admin/orgs/[id]/page.tsx | 8 +- app/admin/orgs/page.tsx | 5 +- app/page.tsx | 3 +- app/sites/[id]/settings/page.tsx | 3 +- app/sites/[id]/uptime/page.tsx | 19 +--- components/dashboard/Chart.tsx | 13 +-- components/dashboard/ExportModal.tsx | 11 +-- components/settings/OrganizationSettings.tsx | 17 ++-- components/settings/SecurityActivityCard.tsx | 4 +- components/settings/TrustedDevicesCard.tsx | 6 +- lib/utils/formatDate.ts | 97 +++++++++++++++++--- lib/utils/notifications.tsx | 13 +-- 13 files changed, 126 insertions(+), 77 deletions(-) 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) } /** From 2242a159c76c2bcecb4bbbe604d91dfe0a0b35ec Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 13:38:51 +0100 Subject: [PATCH 3/5] fix: use 24-hour time in Peak Hours heatmap Axis labels, bucket ranges, and busiest-time callout now use 24-hour format (00:00, 06:00, 12:00, 18:00) instead of AM/PM. --- components/dashboard/PeakHours.tsx | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/components/dashboard/PeakHours.tsx b/components/dashboard/PeakHours.tsx index 47d51b4..3fd2057 100644 --- a/components/dashboard/PeakHours.tsx +++ b/components/dashboard/PeakHours.tsx @@ -13,8 +13,8 @@ interface PeakHoursProps { const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] const BUCKETS = 12 // 2-hour buckets -// Label at bucket index 0=12am, 3=6am, 6=12pm, 9=6pm -const BUCKET_LABELS: Record = { 0: '12am', 3: '6am', 6: '12pm', 9: '6pm' } +// Label at bucket index 0=00:00, 3=06:00, 6=12:00, 9=18:00 +const BUCKET_LABELS: Record = { 0: '00:00', 3: '06:00', 6: '12:00', 9: '18:00' } const HIGHLIGHT_COLORS = [ 'rgba(253,94,15,0.18)', @@ -25,15 +25,12 @@ const HIGHLIGHT_COLORS = [ function formatBucket(bucket: number): string { const hour = bucket * 2 - if (hour === 0) return '12am–2am' - if (hour === 12) return '12pm–2pm' - return hour < 12 ? `${hour}am–${hour + 2}am` : `${hour - 12}pm–${hour - 10}pm` + const end = hour + 2 + return `${String(hour).padStart(2, '0')}:00–${String(end).padStart(2, '0')}:00` } function formatHour(hour: number): string { - if (hour === 0) return '12am' - if (hour === 12) return '12pm' - return hour < 12 ? `${hour}am` : `${hour - 12}pm` + return `${String(hour).padStart(2, '0')}:00` } function getHighlightColor(value: number, max: number): string { @@ -205,7 +202,7 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) { className="absolute text-[10px] text-neutral-400 dark:text-neutral-600 -translate-x-full" style={{ left: '100%' }} > - 12am + 24:00 From 19db02e945def0910a9c33291b14a4bca28857cd Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 13:40:40 +0100 Subject: [PATCH 4/5] fix: lower min_sessions to 1 for journey data visibility Journeys page showed empty state on low-traffic sites because min_sessions=2 filtered out all single-occurrence transitions. --- app/sites/[id]/journeys/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/sites/[id]/journeys/page.tsx b/app/sites/[id]/journeys/page.tsx index ce95a71..55dcc29 100644 --- a/app/sites/[id]/journeys/page.tsx +++ b/app/sites/[id]/journeys/page.tsx @@ -39,10 +39,10 @@ export default function JourneysPage() { const [entryPath, setEntryPath] = useState('') const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions( - siteId, dateRange.start, dateRange.end, depth, 2, entryPath || undefined + siteId, dateRange.start, dateRange.end, depth, 1, entryPath || undefined ) const { data: topPaths, isLoading: topPathsLoading } = useJourneyTopPaths( - siteId, dateRange.start, dateRange.end, 20, 2, entryPath || undefined + siteId, dateRange.start, dateRange.end, 20, 1, entryPath || undefined ) const { data: entryPoints } = useJourneyEntryPoints(siteId, dateRange.start, dateRange.end) const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end) From 11ef95ef45da72eef75f45e551282611c4da7f82 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Sat, 14 Mar 2026 13:40:42 +0100 Subject: [PATCH 5/5] fix: use full day names in Peak Hours busiest-time callout --- components/dashboard/PeakHours.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/components/dashboard/PeakHours.tsx b/components/dashboard/PeakHours.tsx index 3fd2057..541ce5c 100644 --- a/components/dashboard/PeakHours.tsx +++ b/components/dashboard/PeakHours.tsx @@ -12,6 +12,7 @@ interface PeakHoursProps { } const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] +const DAYS_FULL = ['Mondays', 'Tuesdays', 'Wednesdays', 'Thursdays', 'Fridays', 'Saturdays', 'Sundays'] const BUCKETS = 12 // 2-hour buckets // Label at bucket index 0=00:00, 3=06:00, 6=12:00, 9=18:00 const BUCKET_LABELS: Record = { 0: '00:00', 3: '06:00', 6: '12:00', 9: '18:00' } @@ -251,7 +252,7 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) { > Your busiest time is{' '} - {DAYS[bestTime.day]}s at {formatHour(bestTime.bucket * 2)} + {DAYS_FULL[bestTime.day]} at {formatHour(bestTime.bucket * 2)} )}