Merge pull request #49 from ciphera-net/staging
feat: centralise date/time formatting with European conventions
This commit is contained in:
@@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|||||||
|
|
||||||
### Improved
|
### 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.
|
||||||
|
- **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.
|
- **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.
|
||||||
- **Verification status visible in Settings too.** Once your tracking script is verified, the Settings page shows a green confirmation bar instead of the verify button — so you can tell at a glance that everything is working. A "Re-verify" link is still there if you ever need to check again.
|
- **Verification status visible in Settings too.** Once your tracking script is verified, the Settings page shows a green confirmation bar instead of the verify button — so you can tell at a glance that everything is working. A "Re-verify" link is still there if you ever need to check again.
|
||||||
- **Cleaner page paths in your reports.** Pages like `/products?_t=123456` or `/about?session=abc` now correctly show as `/products` and `/about`. Only marketing attribution parameters (like UTM tags) are preserved for traffic source tracking — all other junk parameters are automatically removed, so your Top Pages and Journeys stay clean without us having to chase down every new parameter format.
|
- **Cleaner page paths in your reports.** Pages like `/products?_t=123456` or `/about?session=abc` now correctly show as `/products` and `/about`. Only marketing attribution parameters (like UTM tags) are preserved for traffic source tracking — all other junk parameters are automatically removed, so your Top Pages and Journeys stay clean without us having to chase down every new parameter format.
|
||||||
|
|||||||
@@ -4,13 +4,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import { useParams, useRouter } from 'next/navigation'
|
import { useParams, useRouter } from 'next/navigation'
|
||||||
import { getAdminOrg, grantPlan, type AdminOrgDetail } from '@/lib/api/admin'
|
import { getAdminOrg, grantPlan, type AdminOrgDetail } from '@/lib/api/admin'
|
||||||
import { Button, LoadingOverlay, Select, toast } from '@ciphera-net/ui'
|
import { Button, LoadingOverlay, Select, toast } from '@ciphera-net/ui'
|
||||||
|
import { formatDate, formatDateTime } from '@/lib/utils/formatDate'
|
||||||
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' })
|
|
||||||
}
|
|
||||||
function addMonths(d: Date, months: number) {
|
function addMonths(d: Date, months: number) {
|
||||||
const out = new Date(d)
|
const out = new Date(d)
|
||||||
out.setMonth(out.getMonth() + months)
|
out.setMonth(out.getMonth() + months)
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import { useCallback, useEffect, useState } from 'react'
|
|||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { listAdminOrgs, type AdminOrgSummary } from '@/lib/api/admin'
|
import { listAdminOrgs, type AdminOrgSummary } from '@/lib/api/admin'
|
||||||
import { Button, LoadingOverlay, toast } from '@ciphera-net/ui'
|
import { Button, LoadingOverlay, toast } from '@ciphera-net/ui'
|
||||||
|
import { formatDate } from '@/lib/utils/formatDate'
|
||||||
function formatDate(d: Date) {
|
|
||||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function CopyableOrgId({ id }: { id: string }) {
|
function CopyableOrgId({ id }: { id: string }) {
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { BarChartIcon, LockIcon, ZapIcon, CheckCircleIcon, XIcon, GlobeIcon } fr
|
|||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { getSitesLimitForPlan } from '@/lib/plans'
|
import { getSitesLimitForPlan } from '@/lib/plans'
|
||||||
|
import { formatDate } from '@/lib/utils/formatDate'
|
||||||
|
|
||||||
function DashboardPreview() {
|
function DashboardPreview() {
|
||||||
return (
|
return (
|
||||||
@@ -461,7 +462,7 @@ export default function HomePage() {
|
|||||||
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
|
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
|
||||||
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
|
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
|
||||||
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
||||||
? d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
? formatDate(d)
|
||||||
: null
|
: null
|
||||||
const amount = (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
|
const amount = (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ export default function JourneysPage() {
|
|||||||
const [entryPath, setEntryPath] = useState('')
|
const [entryPath, setEntryPath] = useState('')
|
||||||
|
|
||||||
const { data: transitionsData, isLoading: transitionsLoading } = useJourneyTransitions(
|
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(
|
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: entryPoints } = useJourneyEntryPoints(siteId, dateRange.start, dateRange.end)
|
||||||
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
|
const { data: dashboard } = useDashboard(siteId, dateRange.start, dateRange.end)
|
||||||
|
|||||||
@@ -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 { createReportSchedule, updateReportSchedule, deleteReportSchedule, testReportSchedule, type ReportSchedule, type CreateReportScheduleRequest, type EmailConfig, type WebhookConfig } from '@/lib/api/report-schedules'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } 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 { SettingsFormSkeleton, GoalsListSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
import VerificationModal from '@/components/sites/VerificationModal'
|
import VerificationModal from '@/components/sites/VerificationModal'
|
||||||
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
|
import ScriptSetupBlock from '@/components/sites/ScriptSetupBlock'
|
||||||
@@ -1341,7 +1342,7 @@ export default function SiteSettingsPage() {
|
|||||||
<div className="flex items-center gap-3 mt-1 text-xs text-neutral-400 dark:text-neutral-500">
|
<div className="flex items-center gap-3 mt-1 text-xs text-neutral-400 dark:text-neutral-500">
|
||||||
<span>
|
<span>
|
||||||
Last sent: {schedule.last_sent_at
|
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'}
|
: 'Never'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useTheme } from '@ciphera-net/ui'
|
|||||||
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
import { getAuthErrorMessage } from '@ciphera-net/ui'
|
||||||
import { Button, Modal } from '@ciphera-net/ui'
|
import { Button, Modal } from '@ciphera-net/ui'
|
||||||
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
import { UptimeSkeleton, ChecksSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
|
||||||
|
import { formatDateFull, formatTime, formatDateTimeShort } from '@/lib/utils/formatDate'
|
||||||
import {
|
import {
|
||||||
AreaChart,
|
AreaChart,
|
||||||
Area,
|
Area,
|
||||||
@@ -165,11 +166,7 @@ function StatusBarTooltip({
|
|||||||
}) {
|
}) {
|
||||||
if (!visible) return null
|
if (!visible) return null
|
||||||
|
|
||||||
const formattedDate = new Date(date + 'T00:00:00').toLocaleDateString('en-US', {
|
const formattedDate = formatDateFull(new Date(date + 'T00:00:00'))
|
||||||
weekday: 'short',
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -275,10 +272,7 @@ function ResponseTimeChart({ checks }: { checks: UptimeCheck[] }) {
|
|||||||
.reverse()
|
.reverse()
|
||||||
.filter((c) => c.response_time_ms !== null)
|
.filter((c) => c.response_time_ms !== null)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
time: new Date(c.checked_at).toLocaleTimeString('en-US', {
|
time: formatTime(new Date(c.checked_at)),
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
}),
|
|
||||||
ms: c.response_time_ms as number,
|
ms: c.response_time_ms as number,
|
||||||
status: c.status,
|
status: c.status,
|
||||||
}))
|
}))
|
||||||
@@ -500,12 +494,7 @@ function MonitorCard({
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(check.status)}`} />
|
<div className={`w-2 h-2 rounded-full ${getStatusDotColor(check.status)}`} />
|
||||||
<span className="text-neutral-600 dark:text-neutral-300 text-xs">
|
<span className="text-neutral-600 dark:text-neutral-300 text-xs">
|
||||||
{new Date(check.checked_at).toLocaleString('en-US', {
|
{formatDateTimeShort(new Date(check.checked_at))}
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -292,7 +292,42 @@ export default function PricingSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pricing Grid */}
|
{/* Pricing Grid */}
|
||||||
<div className="grid md:grid-cols-4 divide-y md:divide-y-0 md:divide-x divide-neutral-200 dark:divide-neutral-800">
|
<div className="grid md:grid-cols-5 divide-y md:divide-y-0 md:divide-x divide-neutral-200 dark:divide-neutral-800">
|
||||||
|
{/* Free Plan */}
|
||||||
|
<div className="p-6 flex flex-col relative transition-colors hover:bg-neutral-50/50 dark:hover:bg-neutral-800/50">
|
||||||
|
<div className="mb-8">
|
||||||
|
<h3 className="text-lg font-bold text-neutral-900 dark:text-white mb-2">Free</h3>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 min-h-[40px] mb-4">For trying Pulse on a personal project</p>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-4xl font-bold text-neutral-900 dark:text-white">€0</span>
|
||||||
|
<span className="text-neutral-500 dark:text-neutral-400 font-medium">/forever</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (!user) {
|
||||||
|
initiateOAuthFlow()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.location.href = '/'
|
||||||
|
}}
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full mb-8"
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<ul className="space-y-4 flex-grow">
|
||||||
|
{['1 site', '5k monthly pageviews', '6 months data retention', '100% Data ownership'].map((feature) => (
|
||||||
|
<li key={feature} className="flex items-start gap-3 text-sm text-neutral-600 dark:text-neutral-400">
|
||||||
|
<CheckCircleIcon className="w-5 h-5 shrink-0 text-neutral-400" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
{PLANS.map((plan) => {
|
{PLANS.map((plan) => {
|
||||||
const priceDetails = getPriceDetails(plan.id)
|
const priceDetails = getPriceDetails(plan.id)
|
||||||
const isTeam = plan.id === 'team'
|
const isTeam = plan.id === 'team'
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Checkbox } from '@ciphera-net/ui'
|
|||||||
import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react'
|
import { ArrowUpRight, ArrowDownRight } from '@phosphor-icons/react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { formatTime, formatDateShort, formatDate } from '@/lib/utils/formatDate'
|
||||||
|
|
||||||
const ANNOTATION_COLORS: Record<string, string> = {
|
const ANNOTATION_COLORS: Record<string, string> = {
|
||||||
deploy: '#3b82f6',
|
deploy: '#3b82f6',
|
||||||
@@ -84,8 +85,7 @@ type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration'
|
|||||||
// ─── Helpers ─────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function formatEU(dateStr: string): string {
|
function formatEU(dateStr: string): string {
|
||||||
const [y, m, d] = dateStr.split('-')
|
return formatDate(new Date(dateStr + 'T00:00:00'))
|
||||||
return `${d}/${m}/${y}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Metric configurations ──────────────────────────────────────────
|
// ─── Metric configurations ──────────────────────────────────────────
|
||||||
@@ -216,15 +216,12 @@ export default function Chart({
|
|||||||
const chartData = useMemo(() => data.map((item) => {
|
const chartData = useMemo(() => data.map((item) => {
|
||||||
let formattedDate: string
|
let formattedDate: string
|
||||||
if (interval === 'minute') {
|
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') {
|
} else if (interval === 'hour') {
|
||||||
const d = new Date(item.date)
|
const d = new Date(item.date)
|
||||||
const isMidnight = d.getHours() === 0 && d.getMinutes() === 0
|
formattedDate = formatDateShort(d) + ', ' + formatTime(d)
|
||||||
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' })
|
|
||||||
} else {
|
} else {
|
||||||
formattedDate = new Date(item.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
formattedDate = formatDateShort(new Date(item.date))
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import jsPDF from 'jspdf'
|
|||||||
import autoTable from 'jspdf-autotable'
|
import autoTable from 'jspdf-autotable'
|
||||||
import type { DailyStat } from './Chart'
|
import type { DailyStat } from './Chart'
|
||||||
import { formatNumber, formatDuration } from '@ciphera-net/ui'
|
import { formatNumber, formatDuration } from '@ciphera-net/ui'
|
||||||
|
import { formatDateISO, formatDate, formatDateTime } from '@/lib/utils/formatDate'
|
||||||
import { getReferrerDisplayName, mergeReferrersByDisplayName } from '@/lib/utils/icons'
|
import { getReferrerDisplayName, mergeReferrersByDisplayName } from '@/lib/utils/icons'
|
||||||
import type { TopPage, TopReferrer, CampaignStat } from '@/lib/api/stats'
|
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) {
|
export default function ExportModal({ isOpen, onClose, data, stats, topPages, topReferrers, campaigns }: ExportModalProps) {
|
||||||
const [format, setFormat] = useState<ExportFormat>('csv')
|
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 [includeHeader, setIncludeHeader] = useState(true)
|
||||||
const [isExporting, setIsExporting] = useState(false)
|
const [isExporting, setIsExporting] = useState(false)
|
||||||
const [selectedFields, setSelectedFields] = useState<Record<keyof DailyStat, boolean>>({
|
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)
|
// Metadata (Top Right)
|
||||||
doc.setFontSize(9)
|
doc.setFontSize(9)
|
||||||
doc.setTextColor(150, 150, 150)
|
doc.setTextColor(150, 150, 150)
|
||||||
const generatedDate = new Date().toLocaleDateString()
|
const generatedDate = formatDate(new Date())
|
||||||
const dateRange = data.length > 0
|
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
|
: generatedDate
|
||||||
|
|
||||||
const pageWidth = doc.internal.pageSize.width
|
const pageWidth = doc.internal.pageSize.width
|
||||||
@@ -202,9 +203,7 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
|||||||
const val = row[field]
|
const val = row[field]
|
||||||
if (field === 'date' && typeof val === 'string') {
|
if (field === 'date' && typeof val === 'string') {
|
||||||
const date = new Date(val)
|
const date = new Date(val)
|
||||||
return isHourly
|
return isHourly ? formatDateTime(date) : formatDate(date)
|
||||||
? date.toLocaleString('en-US', { month: 'numeric', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })
|
|
||||||
: date.toLocaleDateString()
|
|
||||||
}
|
}
|
||||||
if (typeof val === 'number') {
|
if (typeof val === 'number') {
|
||||||
if (field === 'bounce_rate') return `${Math.round(val)}%`
|
if (field === 'bounce_rate') return `${Math.round(val)}%`
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ interface PeakHoursProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
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
|
const BUCKETS = 12 // 2-hour buckets
|
||||||
// Label at bucket index 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<number, string> = { 0: '12am', 3: '6am', 6: '12pm', 9: '6pm' }
|
const BUCKET_LABELS: Record<number, string> = { 0: '00:00', 3: '06:00', 6: '12:00', 9: '18:00' }
|
||||||
|
|
||||||
const HIGHLIGHT_COLORS = [
|
const HIGHLIGHT_COLORS = [
|
||||||
'rgba(253,94,15,0.18)',
|
'rgba(253,94,15,0.18)',
|
||||||
@@ -25,15 +26,12 @@ const HIGHLIGHT_COLORS = [
|
|||||||
|
|
||||||
function formatBucket(bucket: number): string {
|
function formatBucket(bucket: number): string {
|
||||||
const hour = bucket * 2
|
const hour = bucket * 2
|
||||||
if (hour === 0) return '12am–2am'
|
const end = hour + 2
|
||||||
if (hour === 12) return '12pm–2pm'
|
return `${String(hour).padStart(2, '0')}:00–${String(end).padStart(2, '0')}:00`
|
||||||
return hour < 12 ? `${hour}am–${hour + 2}am` : `${hour - 12}pm–${hour - 10}pm`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatHour(hour: number): string {
|
function formatHour(hour: number): string {
|
||||||
if (hour === 0) return '12am'
|
return `${String(hour).padStart(2, '0')}:00`
|
||||||
if (hour === 12) return '12pm'
|
|
||||||
return hour < 12 ? `${hour}am` : `${hour - 12}pm`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHighlightColor(value: number, max: number): string {
|
function getHighlightColor(value: number, max: number): string {
|
||||||
@@ -205,7 +203,7 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
|
|||||||
className="absolute text-[10px] text-neutral-400 dark:text-neutral-600 -translate-x-full"
|
className="absolute text-[10px] text-neutral-400 dark:text-neutral-600 -translate-x-full"
|
||||||
style={{ left: '100%' }}
|
style={{ left: '100%' }}
|
||||||
>
|
>
|
||||||
12am
|
24:00
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,7 +252,7 @@ export default function PeakHours({ siteId, dateRange }: PeakHoursProps) {
|
|||||||
>
|
>
|
||||||
Your busiest time is{' '}
|
Your busiest time is{' '}
|
||||||
<span className="text-brand-orange font-medium">
|
<span className="text-brand-orange font-medium">
|
||||||
{DAYS[bestTime.day]}s at {formatHour(bestTime.bucket * 2)}
|
{DAYS_FULL[bestTime.day]} at {formatHour(bestTime.bucket * 2)}
|
||||||
</span>
|
</span>
|
||||||
</motion.p>
|
</motion.p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { getAuditLog, AuditLogEntry, GetAuditLogParams } from '@/lib/api/audit'
|
|||||||
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
|
import { getNotificationSettings, updateNotificationSettings } from '@/lib/api/notification-settings'
|
||||||
import { toast } from '@ciphera-net/ui'
|
import { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } 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 { motion, AnimatePresence } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
AlertTriangleIcon,
|
AlertTriangleIcon,
|
||||||
@@ -776,7 +777,7 @@ export default function OrganizationSettings() {
|
|||||||
{member.user_email || 'Unknown User'}
|
{member.user_email || 'Unknown User'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -813,7 +814,7 @@ export default function OrganizationSettings() {
|
|||||||
{invite.email}
|
{invite.email}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -861,7 +862,7 @@ export default function OrganizationSettings() {
|
|||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{(() => {
|
{(() => {
|
||||||
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null
|
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>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -904,7 +905,7 @@ export default function OrganizationSettings() {
|
|||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{(() => {
|
{(() => {
|
||||||
const d = subscription.current_period_end ? new Date(subscription.current_period_end as string) : null
|
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>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -1016,7 +1017,7 @@ export default function OrganizationSettings() {
|
|||||||
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
|
const ts = subscription.next_invoice_period_end ?? subscription.current_period_end
|
||||||
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
|
const d = ts ? new Date(typeof ts === 'number' ? ts * 1000 : ts) : null
|
||||||
const dateStr = d && !Number.isNaN(d.getTime()) && d.getTime() !== 0
|
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
|
const amount = subscription.next_invoice_amount_due != null && subscription.next_invoice_currency
|
||||||
? (subscription.next_invoice_amount_due / 100).toLocaleString('en-US', {
|
? (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() })}
|
{(invoice.amount_paid / 100).toLocaleString('en-US', { style: 'currency', currency: invoice.currency.toUpperCase() })}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-neutral-500 ml-2">
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1282,7 +1283,7 @@ export default function OrganizationSettings() {
|
|||||||
{entry.id}
|
{entry.id}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-neutral-600 dark:text-neutral-400 whitespace-nowrap">
|
<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>
|
||||||
<td className="px-4 py-3 text-neutral-900 dark:text-white whitespace-nowrap" title={entry.actor_email || entry.actor_id || 'System'}>
|
<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'}
|
{entry.actor_email || entry.actor_id || 'System'}
|
||||||
@@ -1606,7 +1607,7 @@ export default function OrganizationSettings() {
|
|||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: invoicePreview.currency.toUpperCase(),
|
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>
|
<span className="text-neutral-500">(prorated)</span>
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
|
|||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import { getUserActivity, type AuditLogEntry } from '@/lib/api/activity'
|
import { getUserActivity, type AuditLogEntry } from '@/lib/api/activity'
|
||||||
import { Spinner } from '@ciphera-net/ui'
|
import { Spinner } from '@ciphera-net/ui'
|
||||||
import { formatRelativeTime, formatFullDate } from '@/lib/utils/formatDate'
|
import { formatRelativeTime, formatDateTimeFull } from '@/lib/utils/formatDate'
|
||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ export default function SecurityActivityCard() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0 text-right">
|
<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)}
|
{formatRelativeTime(entry.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useState, useCallback } from 'react'
|
|||||||
import { useAuth } from '@/lib/auth/context'
|
import { useAuth } from '@/lib/auth/context'
|
||||||
import { getUserDevices, removeDevice, type TrustedDevice } from '@/lib/api/devices'
|
import { getUserDevices, removeDevice, type TrustedDevice } from '@/lib/api/devices'
|
||||||
import { Spinner, toast } from '@ciphera-net/ui'
|
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 {
|
function getDeviceIcon(hint: string): string {
|
||||||
const h = hint.toLowerCase()
|
const h = hint.toLowerCase()
|
||||||
@@ -101,11 +101,11 @@ export default function TrustedDevicesCard() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5 text-xs text-neutral-500 dark:text-neutral-400">
|
<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)}
|
First seen {formatRelativeTime(device.first_seen_at)}
|
||||||
</span>
|
</span>
|
||||||
<span>·</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)}
|
Last seen {formatRelativeTime(device.last_seen_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ export const PLAN_ID_SOLO = 'solo'
|
|||||||
export const PLAN_ID_TEAM = 'team'
|
export const PLAN_ID_TEAM = 'team'
|
||||||
export const PLAN_ID_BUSINESS = 'business'
|
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 {
|
export function getSitesLimitForPlan(planId: string | null | undefined): number | null {
|
||||||
if (!planId || planId === 'free') return null
|
if (!planId || planId === 'free') return 1
|
||||||
switch (planId) {
|
switch (planId) {
|
||||||
case 'solo': return 1
|
case 'solo': return 1
|
||||||
case 'team': return 5
|
case 'team': return 5
|
||||||
|
|||||||
@@ -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 {
|
export function formatRelativeTime(dateStr: string): string {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
@@ -11,20 +90,16 @@ export function formatRelativeTime(dateStr: string): string {
|
|||||||
if (diffHr < 24) return `${diffHr}h ago`
|
if (diffHr < 24) return `${diffHr}h ago`
|
||||||
if (diffDay < 7) return `${diffDay}d ago`
|
if (diffDay < 7) return `${diffDay}d ago`
|
||||||
|
|
||||||
return date.toLocaleDateString('en-US', {
|
return formatDateShort(date)
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: date.getFullYear() !== now.getFullYear() ? 'numeric' : undefined,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatFullDate(dateStr: string): string {
|
/** 14 Mar, 14:30 — compact date + time (uptime checks, recent activity) */
|
||||||
return new Date(dateStr).toLocaleString('en-US', {
|
export function formatDateTimeShort(d: Date): string {
|
||||||
weekday: 'short',
|
return d.toLocaleDateString(LOCALE, {
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
month: 'short',
|
||||||
hour: 'numeric',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
|
hour12: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,11 @@
|
|||||||
import { AlertTriangleIcon, CheckCircleIcon } from '@ciphera-net/ui'
|
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").
|
* Formats a date string as a human-readable relative time (e.g. "5m ago", "2h ago").
|
||||||
*/
|
*/
|
||||||
export function formatTimeAgo(dateStr: string): string {
|
export function formatTimeAgo(dateStr: string): string {
|
||||||
const d = new Date(dateStr)
|
return formatRelativeTime(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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user