Merge pull request #24 from ciphera-net/staging
[PULSE-54] Analytics chart improvements and live dashboard
This commit is contained in:
29
CHANGELOG.md
29
CHANGELOG.md
@@ -4,38 +4,49 @@ All notable changes to Pulse (frontend and product) are documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and Pulse uses [Semantic Versioning](https://semver.org/spec/v2.0.0.html) with a **0.x.y** version scheme while in initial development. The leading `0` indicates that the public API and behaviour may change until we release **1.0.0**.
|
||||
|
||||
## [0.5.0-alpha] - 2026-02-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Live chart and KPIs.** Chart and stats refresh every 30 seconds. "Live · Xs ago" indicator with green dot in the chart corner counts in real time.
|
||||
- **Polling indicator.** Shows when data was last updated (bottom-right of chart card).
|
||||
|
||||
### Changed
|
||||
|
||||
- **Analytics chart improvements.** Clearer labels, compare mode shows which period you're comparing against, mini trend lines on each stat, export chart as image, and a better experience on mobile.
|
||||
- **Trend context for all date ranges.** "vs yesterday" or "vs previous 7 days" now shows for Today, 7 days, and 30 days.
|
||||
- **Compare label shortened.** "Compare with previous period" → "Compare".
|
||||
- **Chart axes layout.** Y-axis space matches X-axis; metric label moved above chart; compact duration format for axis ticks.
|
||||
|
||||
## [0.4.0-alpha] - 2026-02-11
|
||||
|
||||
### Changed
|
||||
|
||||
- **Campaigns block improvements (PULSE-53).** The Campaigns card now supports sortable columns (Source, Medium, Campaign, Visitors, Pageviews), source favicons with display names (matching Top Referrers), a Pageviews column, and em-dash (—) for empty Medium/Campaign. Loading state uses a skeleton instead of a spinner. Rows use stable keys for better React reconciliation. An Export button exports campaigns to CSV; the main dashboard Export (PDF/Excel) also includes campaigns when available.
|
||||
- **Campaigns block improvements (PULSE-53).** Sortable columns, favicons and friendly names for sources, pageviews column, and export to CSV. Full dashboard export now includes campaigns.
|
||||
|
||||
## [0.3.0-alpha] - 2026-02-11
|
||||
|
||||
### Changed
|
||||
|
||||
- **Top Referrers favicons (PULSE-52).** The Top Referrers card now shows real site favicons (e.g. Google, ChatGPT, Instagram) when the referrer is a domain or URL. “Direct” and “Unknown” keep the globe icon; if a favicon fails to load, the previous icon is shown as fallback.
|
||||
- **Referrer display names.** Referrers now show friendly names (e.g. “Google”, “Kagi”) using a heuristic from the hostname plus a small override map for famous brands (ChatGPT, LinkedIn, X, etc.). New sites get a sensible name without being added to a list.
|
||||
- **Top Referrers merged by name.** Rows that map to the same display name (e.g. `chatgpt.com` and `https://chatgpt.com/...`) are merged into one row with combined pageviews, so the same source no longer appears twice.
|
||||
- **Top Referrers favicons and names (PULSE-52).** Real favicons (Google, ChatGPT, etc.) and friendly names instead of raw URLs. Same referrer from different URLs is merged into one row.
|
||||
|
||||
## [0.2.0-alpha] - 2026-02-11
|
||||
|
||||
### Added
|
||||
|
||||
- **Smarter unique visitor counts.** If someone opens your site in several tabs or windows, they’re now counted as one visitor by default, so your stats better reflect real people.
|
||||
- **Control over how visitors are counted.** You can switch back to “one visitor per tab” (more private, no lasting identifier) by adding an option to your script embed. The dashboard shows the right snippet for both options.
|
||||
- **Optional expiry for the visitor ID.** You can set how long the cross-tab visitor ID is kept (e.g. 24 hours); after that it’s refreshed automatically.
|
||||
- **Smarter unique visitor counts.** Visitors opening several tabs/windows are counted as one person.
|
||||
- **Visitor count options.** Choose "one per tab" (more private) or "one per person" (default). Dashboard shows the right embed snippet for each.
|
||||
|
||||
## [0.1.0-alpha] - 2026-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- Initial changelog and release process.
|
||||
- Release documentation in `docs/releasing.md` and optional changelog check script.
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.4.0-alpha...HEAD
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.5.0-alpha...HEAD
|
||||
[0.5.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.4.0-alpha...v0.5.0-alpha
|
||||
[0.4.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...v0.4.0-alpha
|
||||
[0.3.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.2.0-alpha...v0.3.0-alpha
|
||||
[0.2.0-alpha]: https://github.com/ciphera-net/pulse/compare/v0.1.0-alpha...v0.2.0-alpha
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useSearchParams, useRouter } from 'next/navigation'
|
||||
import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
|
||||
import { toast } from '@ciphera-net/ui'
|
||||
@@ -53,6 +53,8 @@ export default function PublicDashboardPage() {
|
||||
// Previous period data
|
||||
const [prevStats, setPrevStats] = useState<Stats | undefined>(undefined)
|
||||
const [prevDailyStats, setPrevDailyStats] = useState<DailyStat[] | undefined>(undefined)
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
|
||||
const [, setTick] = useState(0)
|
||||
|
||||
const getPreviousDateRange = (start: string, end: string) => {
|
||||
const startDate = new Date(start)
|
||||
@@ -78,23 +80,13 @@ export default function PublicDashboardPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-refresh interval (for realtime)
|
||||
// * Tick every 1s so "Live · Xs ago" counts in real time
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
// Only refresh realtime count if we have data
|
||||
if (data && !isPasswordProtected) {
|
||||
loadRealtime()
|
||||
}
|
||||
}, 30000) // 30 seconds
|
||||
|
||||
const interval = setInterval(() => setTick((t) => t + 1), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [data, isPasswordProtected, dateRange, password])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval])
|
||||
|
||||
const loadRealtime = async () => {
|
||||
const loadRealtime = useCallback(async () => {
|
||||
try {
|
||||
const auth = {
|
||||
password,
|
||||
@@ -114,9 +106,9 @@ export default function PublicDashboardPage() {
|
||||
} catch (error) {
|
||||
// Silently fail for realtime updates
|
||||
}
|
||||
}
|
||||
}, [siteId, password, captchaId, captchaSolution, captchaToken, data])
|
||||
|
||||
const loadDashboard = async (silent = false) => {
|
||||
const loadDashboard = useCallback(async (silent = false) => {
|
||||
try {
|
||||
if (!silent) setLoading(true)
|
||||
|
||||
@@ -153,6 +145,7 @@ export default function PublicDashboardPage() {
|
||||
setData(dashboardData)
|
||||
setPrevStats(prevStatsData)
|
||||
setPrevDailyStats(prevDailyStatsData)
|
||||
setLastUpdatedAt(Date.now())
|
||||
|
||||
setIsPasswordProtected(false)
|
||||
// Reset captcha
|
||||
@@ -177,7 +170,22 @@ export default function PublicDashboardPage() {
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, password, captchaId, captchaSolution, captchaToken])
|
||||
|
||||
// * Auto-refresh interval: chart, KPIs, and realtime count update every 30 seconds
|
||||
useEffect(() => {
|
||||
if (data && !isPasswordProtected) {
|
||||
const interval = setInterval(() => {
|
||||
loadDashboard(true)
|
||||
loadRealtime()
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}
|
||||
}, [data, isPasswordProtected, dateRange, todayInterval, multiDayInterval, password, loadDashboard, loadRealtime])
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, loadDashboard])
|
||||
|
||||
const handlePasswordSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -285,13 +293,13 @@ export default function PublicDashboardPage() {
|
||||
|
||||
{/* Realtime Indicator - Desktop */}
|
||||
<div className="hidden md:flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 self-end mb-1">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-400">
|
||||
{realtime_visitors} current visitors
|
||||
</span>
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||
</span>
|
||||
<span className="text-sm font-medium text-green-700 dark:text-green-400">
|
||||
{realtime_visitors} current visitors
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -371,6 +379,7 @@ export default function PublicDashboardPage() {
|
||||
setTodayInterval={setTodayInterval}
|
||||
multiDayInterval={multiDayInterval}
|
||||
setMultiDayInterval={setMultiDayInterval}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useAuth } from '@/lib/auth/context'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { motion } from 'framer-motion'
|
||||
import { getSite, type Site } from '@/lib/api/sites'
|
||||
@@ -57,6 +57,8 @@ export default function SiteDashboardPage() {
|
||||
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
||||
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
|
||||
const [isSettingsLoaded, setIsSettingsLoaded] = useState(false)
|
||||
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
|
||||
const [, setTick] = useState(0)
|
||||
|
||||
// Load settings from localStorage
|
||||
useEffect(() => {
|
||||
@@ -126,42 +128,36 @@ export default function SiteDashboardPage() {
|
||||
}, [todayInterval, multiDayInterval, isSettingsLoaded]) // dateRange is handled in saveSettings/onChange
|
||||
|
||||
useEffect(() => {
|
||||
if (isSettingsLoaded) {
|
||||
loadData()
|
||||
}
|
||||
if (isSettingsLoaded) loadData()
|
||||
const interval = setInterval(() => {
|
||||
loadData(true)
|
||||
loadRealtime()
|
||||
}, 30000) // Update every 30 seconds
|
||||
}, 30000)
|
||||
return () => clearInterval(interval)
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded])
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime])
|
||||
|
||||
const getPreviousDateRange = (start: string, end: string) => {
|
||||
// * Tick every 1s so "Live · Xs ago" counts in real time
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setTick((t) => t + 1), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const getPreviousDateRange = useCallback((start: string, end: string) => {
|
||||
const startDate = new Date(start)
|
||||
const endDate = new Date(end)
|
||||
const duration = endDate.getTime() - startDate.getTime()
|
||||
|
||||
// * If duration is 0 (Today), set previous range to yesterday
|
||||
if (duration === 0) {
|
||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
const prevStart = prevEnd
|
||||
return {
|
||||
start: prevStart.toISOString().split('T')[0],
|
||||
end: prevEnd.toISOString().split('T')[0]
|
||||
}
|
||||
return { start: prevEnd.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}
|
||||
|
||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
const prevStart = new Date(prevEnd.getTime() - duration)
|
||||
|
||||
return {
|
||||
start: prevStart.toISOString().split('T')[0],
|
||||
end: prevEnd.toISOString().split('T')[0]
|
||||
}
|
||||
}
|
||||
return { start: prevStart.toISOString().split('T')[0], end: prevEnd.toISOString().split('T')[0] }
|
||||
}, [])
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = useCallback(async (silent = false) => {
|
||||
try {
|
||||
setLoading(true)
|
||||
if (!silent) setLoading(true)
|
||||
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
||||
|
||||
const [data, prevStatsData, prevDailyStatsData, campaignsData] = await Promise.all([
|
||||
@@ -200,21 +196,24 @@ export default function SiteDashboardPage() {
|
||||
setPerformanceByPage(data.performance_by_page ?? null)
|
||||
setGoalCounts(Array.isArray(data.goal_counts) ? data.goal_counts : [])
|
||||
setCampaigns(Array.isArray(campaignsData) ? campaignsData : [])
|
||||
setLastUpdatedAt(Date.now())
|
||||
} catch (error: unknown) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load data: ' + ((error as Error)?.message || 'Unknown error'))
|
||||
if (!silent) {
|
||||
toast.error(getAuthErrorMessage(error) || 'Failed to load data: ' + ((error as Error)?.message || 'Unknown error'))
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}
|
||||
}, [siteId, dateRange, todayInterval, multiDayInterval])
|
||||
|
||||
const loadRealtime = async () => {
|
||||
const loadRealtime = useCallback(async () => {
|
||||
try {
|
||||
const data = await getRealtime(siteId)
|
||||
setRealtime(data.visitors)
|
||||
} catch (error) {
|
||||
// Silently fail for realtime updates
|
||||
}
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
if (loading) {
|
||||
return <LoadingOverlay logoSrc="/pulse_icon_no_margins.png" title="Pulse" />
|
||||
@@ -359,6 +358,7 @@ export default function SiteDashboardPage() {
|
||||
setTodayInterval={setTodayInterval}
|
||||
multiDayInterval={multiDayInterval}
|
||||
setMultiDayInterval={setMultiDayInterval}
|
||||
lastUpdatedAt={lastUpdatedAt}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useState, useMemo, useRef, useCallback } from 'react'
|
||||
import { useTheme } from '@ciphera-net/ui'
|
||||
import {
|
||||
AreaChart,
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
ReferenceLine,
|
||||
} from 'recharts'
|
||||
import type { TooltipProps } from 'recharts'
|
||||
import { formatNumber, formatDuration } from '@/lib/utils/format'
|
||||
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select } from '@ciphera-net/ui'
|
||||
import { formatNumber, formatDuration, formatUpdatedAgo } from '@/lib/utils/format'
|
||||
import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui'
|
||||
import { Checkbox } from '@ciphera-net/ui'
|
||||
|
||||
const COLORS = {
|
||||
@@ -67,6 +67,10 @@ interface ChartProps {
|
||||
setTodayInterval: (interval: 'minute' | 'hour') => void
|
||||
multiDayInterval: 'hour' | 'day'
|
||||
setMultiDayInterval: (interval: 'hour' | 'day') => void
|
||||
/** Optional: callback when user requests chart export (parent can open ExportModal or handle export) */
|
||||
onExportChart?: () => void
|
||||
/** Optional: timestamp of last data fetch for "Live · Xs ago" indicator */
|
||||
lastUpdatedAt?: number | null
|
||||
}
|
||||
|
||||
type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration'
|
||||
@@ -80,6 +84,7 @@ function ChartTooltip({
|
||||
metricLabel,
|
||||
formatNumberFn,
|
||||
showComparison,
|
||||
prevPeriodLabel,
|
||||
colors,
|
||||
}: {
|
||||
active?: boolean
|
||||
@@ -89,6 +94,7 @@ function ChartTooltip({
|
||||
metricLabel: string
|
||||
formatNumberFn: (n: number) => string
|
||||
showComparison: boolean
|
||||
prevPeriodLabel?: string
|
||||
colors: typeof CHART_COLORS_LIGHT
|
||||
}) {
|
||||
if (!active || !payload?.length || !label) return null
|
||||
@@ -140,7 +146,7 @@ function ChartTooltip({
|
||||
</div>
|
||||
{hasPrev && (
|
||||
<div className="mt-1.5 flex items-center gap-1.5 text-xs" style={{ color: colors.textMuted }}>
|
||||
<span>vs {formatValue(prev as number)} prev</span>
|
||||
<span>vs {formatValue(prev as number)} {prevPeriodLabel ? `(${prevPeriodLabel})` : 'prev'}</span>
|
||||
{delta !== null && (
|
||||
<span
|
||||
className="font-medium"
|
||||
@@ -164,6 +170,89 @@ function formatAxisValue(value: number): string {
|
||||
return String(value)
|
||||
}
|
||||
|
||||
// * Compact duration for Y-axis ticks (avoids truncation: "5m" not "5m 0s")
|
||||
function formatAxisDuration(seconds: number): string {
|
||||
if (!seconds) return '0s'
|
||||
const m = Math.floor(seconds / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
if (m > 0) return s > 0 ? `${m}m ${s}s` : `${m}m`
|
||||
return `${s}s`
|
||||
}
|
||||
|
||||
// * Returns human-readable label for the previous comparison period (e.g. "Feb 10" or "Jan 5 – Feb 4")
|
||||
function getPrevDateRangeLabel(dateRange: { start: string; end: string }): string {
|
||||
const startDate = new Date(dateRange.start)
|
||||
const endDate = new Date(dateRange.end)
|
||||
const duration = endDate.getTime() - startDate.getTime()
|
||||
|
||||
if (duration === 0) {
|
||||
const prev = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
return prev.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
const prevEnd = new Date(startDate.getTime() - 24 * 60 * 60 * 1000)
|
||||
const prevStart = new Date(prevEnd.getTime() - duration)
|
||||
const fmt = (d: Date) => d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
return `${fmt(prevStart)} – ${fmt(prevEnd)}`
|
||||
}
|
||||
|
||||
// * Returns short trend context (e.g. "vs yesterday", "vs previous 7 days")
|
||||
function getTrendContext(dateRange: { start: string; end: string }): string {
|
||||
const startDate = new Date(dateRange.start)
|
||||
const endDate = new Date(dateRange.end)
|
||||
const duration = endDate.getTime() - startDate.getTime()
|
||||
|
||||
if (duration === 0) return 'vs yesterday'
|
||||
const days = Math.round(duration / (24 * 60 * 60 * 1000))
|
||||
if (days === 1) return 'vs yesterday'
|
||||
return `vs previous ${days} days`
|
||||
}
|
||||
|
||||
// * Mini sparkline SVG for KPI cards
|
||||
function Sparkline({
|
||||
data,
|
||||
dataKey,
|
||||
color,
|
||||
width = 56,
|
||||
height = 20,
|
||||
}: {
|
||||
data: Array<Record<string, unknown>>
|
||||
dataKey: string
|
||||
color: string
|
||||
width?: number
|
||||
height?: number
|
||||
}) {
|
||||
if (!data.length) return null
|
||||
const values = data.map((d) => Number(d[dataKey] ?? 0))
|
||||
const max = Math.max(...values, 1)
|
||||
const min = Math.min(...values, 0)
|
||||
const range = max - min || 1
|
||||
const padding = 2
|
||||
const w = width - padding * 2
|
||||
const h = height - padding * 2
|
||||
|
||||
const points = values.map((v, i) => {
|
||||
const x = padding + (i / Math.max(values.length - 1, 1)) * w
|
||||
const y = padding + h - ((v - min) / range) * h
|
||||
return `${x},${y}`
|
||||
})
|
||||
|
||||
const pathD = points.length > 1 ? `M ${points.join(' L ')}` : `M ${points[0]} L ${points[0]}`
|
||||
|
||||
return (
|
||||
<svg width={width} height={height} className="flex-shrink-0" aria-hidden>
|
||||
<path
|
||||
d={pathD}
|
||||
fill="none"
|
||||
stroke={color}
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Chart({
|
||||
data,
|
||||
prevData,
|
||||
@@ -174,12 +263,36 @@ export default function Chart({
|
||||
todayInterval,
|
||||
setTodayInterval,
|
||||
multiDayInterval,
|
||||
setMultiDayInterval
|
||||
setMultiDayInterval,
|
||||
onExportChart,
|
||||
lastUpdatedAt,
|
||||
}: ChartProps) {
|
||||
const [metric, setMetric] = useState<MetricType>('visitors')
|
||||
const [showComparison, setShowComparison] = useState(false)
|
||||
const chartContainerRef = useRef<HTMLDivElement>(null)
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
||||
const handleExportChart = useCallback(async () => {
|
||||
if (onExportChart) {
|
||||
onExportChart()
|
||||
return
|
||||
}
|
||||
if (!chartContainerRef.current) return
|
||||
try {
|
||||
const { toPng } = await import('html-to-image')
|
||||
const dataUrl = await toPng(chartContainerRef.current, {
|
||||
cacheBust: true,
|
||||
backgroundColor: resolvedTheme === 'dark' ? '#171717' : '#ffffff',
|
||||
})
|
||||
const link = document.createElement('a')
|
||||
link.download = `chart-${dateRange.start}-${dateRange.end}.png`
|
||||
link.href = dataUrl
|
||||
link.click()
|
||||
} catch {
|
||||
// Fallback: do nothing if export fails
|
||||
}
|
||||
}, [onExportChart, dateRange, resolvedTheme])
|
||||
|
||||
const colors = useMemo(
|
||||
() => (resolvedTheme === 'dark' ? CHART_COLORS_DARK : CHART_COLORS_LIGHT),
|
||||
[resolvedTheme]
|
||||
@@ -265,12 +378,16 @@ export default function Chart({
|
||||
const activeMetric = metrics.find((m) => m.id === metric) || metrics[0]
|
||||
const chartMetric = metric
|
||||
const metricLabel = metrics.find(m => m.id === metric)?.label || 'visitors'
|
||||
const prevPeriodLabel = prevData?.length ? getPrevDateRangeLabel(dateRange) : ''
|
||||
const trendContext = getTrendContext(dateRange)
|
||||
|
||||
const avg = chartData.length
|
||||
? chartData.reduce((s, d) => s + (d[chartMetric] as number), 0) / chartData.length
|
||||
: 0
|
||||
|
||||
const hasPrev = !!(prevData?.length && showComparison)
|
||||
const hasData = data.length > 0
|
||||
const hasAnyNonZero = hasData && chartData.some((d) => (d[chartMetric] as number) > 0)
|
||||
|
||||
// * In hourly view, only show X-axis labels at 12:00 AM (date + 12:00 AM).
|
||||
const midnightTicks =
|
||||
@@ -290,44 +407,77 @@ export default function Chart({
|
||||
const dayTicks = interval === 'day' && chartData.length > 0 ? chartData.map((c) => c.date) : undefined
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm">
|
||||
<div
|
||||
ref={chartContainerRef}
|
||||
className="bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-800 rounded-2xl overflow-hidden shadow-sm relative"
|
||||
role="region"
|
||||
aria-label={`Analytics chart showing ${metricLabel} over time`}
|
||||
>
|
||||
{/* * Subtle live/updated indicator in bottom-right corner */}
|
||||
{lastUpdatedAt != null && (
|
||||
<div
|
||||
className="absolute bottom-3 right-6 flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400 pointer-events-none"
|
||||
title="Data refreshes every 30 seconds"
|
||||
>
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-1.5 w-1.5 bg-green-500" />
|
||||
</span>
|
||||
Live · {formatUpdatedAgo(lastUpdatedAt)}
|
||||
</div>
|
||||
)}
|
||||
{/* Stats Header (Interactive Tabs) */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 divide-x divide-neutral-200 dark:divide-neutral-800 border-b border-neutral-200 dark:border-neutral-800">
|
||||
{metrics.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
onClick={() => setMetric(item.id as MetricType)}
|
||||
aria-pressed={metric === item.id}
|
||||
aria-label={`Show ${item.label} chart`}
|
||||
className={`
|
||||
p-6 text-left transition-colors relative group
|
||||
p-4 sm:p-6 text-left transition-colors relative group
|
||||
hover:bg-neutral-50 dark:hover:bg-neutral-800/50
|
||||
${metric === item.id ? 'bg-neutral-50 dark:bg-neutral-800/50' : ''}
|
||||
cursor-pointer
|
||||
cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-500 focus-visible:ring-offset-2
|
||||
`}
|
||||
>
|
||||
<div className={`text-xs font-semibold uppercase tracking-wider mb-1 flex items-center gap-2 ${metric === item.id ? 'text-neutral-900 dark:text-white' : 'text-neutral-500'}`}>
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<span className="text-xl sm:text-2xl font-bold text-neutral-900 dark:text-white">
|
||||
{item.value}
|
||||
</span>
|
||||
{item.trend !== null && (
|
||||
<span className={`flex items-center text-sm font-medium ${
|
||||
(item.invertTrend ? -item.trend : item.trend) > 0
|
||||
? 'text-emerald-600 dark:text-emerald-500'
|
||||
: (item.invertTrend ? -item.trend : item.trend) < 0
|
||||
? 'text-red-600 dark:text-red-500'
|
||||
: 'text-neutral-500'
|
||||
}`}>
|
||||
{(item.invertTrend ? -item.trend : item.trend) > 0 ? (
|
||||
<ArrowUpRightIcon className="w-3 h-3 mr-0.5" />
|
||||
) : (item.invertTrend ? -item.trend : item.trend) < 0 ? (
|
||||
<ArrowDownRightIcon className="w-3 h-3 mr-0.5" />
|
||||
) : null}
|
||||
{Math.abs(item.trend)}%
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center text-sm font-medium">
|
||||
{item.trend !== null ? (
|
||||
<>
|
||||
<span className={
|
||||
(item.invertTrend ? -item.trend : item.trend) > 0
|
||||
? 'text-emerald-600 dark:text-emerald-500'
|
||||
: (item.invertTrend ? -item.trend : item.trend) < 0
|
||||
? 'text-red-600 dark:text-red-500'
|
||||
: 'text-neutral-500'
|
||||
}>
|
||||
{(item.invertTrend ? -item.trend : item.trend) > 0 ? (
|
||||
<ArrowUpRightIcon className="w-3 h-3 mr-0.5 inline" />
|
||||
) : (item.invertTrend ? -item.trend : item.trend) < 0 ? (
|
||||
<ArrowDownRightIcon className="w-3 h-3 mr-0.5 inline" />
|
||||
) : null}
|
||||
{Math.abs(item.trend)}%
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-neutral-500 dark:text-neutral-400">—</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-0.5">{trendContext}</p>
|
||||
{hasData && (
|
||||
<div className="mt-2">
|
||||
<Sparkline data={chartData} dataKey={item.id} color={item.color} />
|
||||
</div>
|
||||
)}
|
||||
{metric === item.id && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1" style={{ backgroundColor: item.color }} />
|
||||
)}
|
||||
@@ -355,51 +505,71 @@ export default function Chart({
|
||||
className="h-2 w-2 rounded-full border border-dashed"
|
||||
style={{ borderColor: colors.axis }}
|
||||
/>
|
||||
Previous
|
||||
Previous{prevPeriodLabel ? ` (${prevPeriodLabel})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right side: Controls */}
|
||||
<div className="flex items-center gap-3 self-end sm:self-auto">
|
||||
{dateRange.start === dateRange.end && (
|
||||
<Select
|
||||
value={todayInterval}
|
||||
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
|
||||
options={[
|
||||
{ value: 'minute', label: '1 min' },
|
||||
{ value: 'hour', label: '1 hour' },
|
||||
]}
|
||||
className="min-w-[100px]"
|
||||
/>
|
||||
)}
|
||||
{dateRange.start !== dateRange.end && (
|
||||
<Select
|
||||
value={multiDayInterval}
|
||||
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
|
||||
options={[
|
||||
{ value: 'hour', label: '1 hour' },
|
||||
{ value: 'day', label: '1 day' },
|
||||
]}
|
||||
className="min-w-[100px]"
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-3 self-end sm:self-auto">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">Group by:</span>
|
||||
{dateRange.start === dateRange.end && (
|
||||
<Select
|
||||
value={todayInterval}
|
||||
onChange={(value) => setTodayInterval(value as 'minute' | 'hour')}
|
||||
options={[
|
||||
{ value: 'minute', label: '1 min' },
|
||||
{ value: 'hour', label: '1 hour' },
|
||||
]}
|
||||
className="min-w-[100px]"
|
||||
/>
|
||||
)}
|
||||
{dateRange.start !== dateRange.end && (
|
||||
<Select
|
||||
value={multiDayInterval}
|
||||
onChange={(value) => setMultiDayInterval(value as 'hour' | 'day')}
|
||||
options={[
|
||||
{ value: 'hour', label: '1 hour' },
|
||||
{ value: 'day', label: '1 day' },
|
||||
]}
|
||||
className="min-w-[100px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{prevData?.length ? (
|
||||
<Checkbox
|
||||
checked={showComparison}
|
||||
onCheckedChange={setShowComparison}
|
||||
label="Compare"
|
||||
/>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Checkbox
|
||||
checked={showComparison}
|
||||
onCheckedChange={setShowComparison}
|
||||
label="Compare"
|
||||
/>
|
||||
{showComparison && prevPeriodLabel && (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
({prevPeriodLabel})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleExportChart}
|
||||
disabled={!hasData}
|
||||
className="gap-1.5 py-1.5 px-3 text-sm text-neutral-600 dark:text-neutral-400"
|
||||
>
|
||||
<DownloadIcon className="w-4 h-4" />
|
||||
Export chart
|
||||
</Button>
|
||||
|
||||
{/* Vertical Separator */}
|
||||
<div className="h-4 w-px bg-neutral-200 dark:bg-neutral-800" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{data.length === 0 ? (
|
||||
{!hasData ? (
|
||||
<div className="flex h-[320px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
|
||||
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
|
||||
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
||||
@@ -407,10 +577,22 @@ export default function Chart({
|
||||
</p>
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try a different date range</p>
|
||||
</div>
|
||||
) : !hasAnyNonZero ? (
|
||||
<div className="flex h-[320px] flex-col items-center justify-center gap-3 rounded-lg border border-dashed border-neutral-200 dark:border-neutral-700 bg-neutral-50/50 dark:bg-neutral-800/30">
|
||||
<BarChartIcon className="h-12 w-12 text-neutral-300 dark:text-neutral-600" aria-hidden />
|
||||
<p className="text-sm font-medium text-neutral-500 dark:text-neutral-400">
|
||||
No {metricLabel.toLowerCase()} data for this period
|
||||
</p>
|
||||
<p className="text-xs text-neutral-400 dark:text-neutral-500">Try selecting another metric or date range</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[360px] w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 10, right: 8, left: -16, bottom: 0 }}>
|
||||
<div className="h-[360px] w-full flex flex-col">
|
||||
<div className="text-xs font-medium mb-1 flex-shrink-0" style={{ color: colors.axis }}>
|
||||
{metricLabel}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, left: 24, bottom: 24 }}>
|
||||
<defs>
|
||||
<linearGradient id={`gradient-${metric}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={activeMetric.color} stopOpacity={0.35} />
|
||||
@@ -434,9 +616,10 @@ export default function Chart({
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
domain={[0, 'auto']}
|
||||
width={24}
|
||||
tickFormatter={(val) => {
|
||||
if (metric === 'bounce_rate') return `${val}%`
|
||||
if (metric === 'avg_duration') return formatDuration(val)
|
||||
if (metric === 'avg_duration') return formatAxisDuration(val)
|
||||
return formatAxisValue(val)
|
||||
}}
|
||||
/>
|
||||
@@ -453,6 +636,7 @@ export default function Chart({
|
||||
metricLabel={metricLabel}
|
||||
formatNumberFn={formatNumber}
|
||||
showComparison={hasPrev}
|
||||
prevPeriodLabel={prevPeriodLabel}
|
||||
colors={colors}
|
||||
/>
|
||||
)}
|
||||
@@ -465,6 +649,12 @@ export default function Chart({
|
||||
stroke={colors.axis}
|
||||
strokeDasharray="4 4"
|
||||
strokeOpacity={0.7}
|
||||
label={{
|
||||
value: `Avg: ${metric === 'bounce_rate' ? `${Math.round(avg)}%` : metric === 'avg_duration' ? formatAxisDuration(avg) : formatAxisValue(avg)}`,
|
||||
position: 'insideTopRight',
|
||||
fill: colors.axis,
|
||||
fontSize: 11,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -511,8 +701,9 @@ export default function Chart({
|
||||
animationDuration={500}
|
||||
animationEasing="ease-out"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,18 @@ export function getDateRange(days: number): { start: string; end: string } {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format "updated X ago" for polling indicators (e.g. "Just now", "12 seconds ago")
|
||||
*/
|
||||
export function formatUpdatedAgo(timestamp: number): string {
|
||||
const diff = Math.floor((Date.now() - timestamp) / 1000)
|
||||
if (diff < 5) return 'Just now'
|
||||
if (diff < 60) return `${diff} seconds ago`
|
||||
if (diff < 120) return '1 minute ago'
|
||||
const minutes = Math.floor(diff / 60)
|
||||
return `${minutes} minutes ago`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format relative time (e.g., "2 hours ago")
|
||||
*/
|
||||
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.1.3",
|
||||
"version": "0.4.0-alpha",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.1.3",
|
||||
"version": "0.4.0-alpha",
|
||||
"dependencies": {
|
||||
"@ciphera-net/ui": "^0.0.49",
|
||||
"@ducanh2912/next-pwa": "^10.2.9",
|
||||
@@ -14,6 +14,7 @@
|
||||
"country-flag-icons": "^1.6.4",
|
||||
"d3-scale": "^4.0.2",
|
||||
"framer-motion": "^12.23.26",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18n-iso-countries": "^7.14.0",
|
||||
"iso-3166-2": "^1.0.0",
|
||||
"jspdf": "^4.0.0",
|
||||
@@ -6092,6 +6093,12 @@
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/html-to-image": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz",
|
||||
"integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.4.0-alpha",
|
||||
"version": "0.5.0-alpha",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
@@ -16,6 +16,7 @@
|
||||
"country-flag-icons": "^1.6.4",
|
||||
"d3-scale": "^4.0.2",
|
||||
"framer-motion": "^12.23.26",
|
||||
"html-to-image": "^1.11.13",
|
||||
"i18n-iso-countries": "^7.14.0",
|
||||
"iso-3166-2": "^1.0.0",
|
||||
"jspdf": "^4.0.0",
|
||||
|
||||
Reference in New Issue
Block a user