From c623ae1e9b9cb9c437f142a9bbdfce5f0becd975 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Feb 2026 20:04:33 +0100 Subject: [PATCH 01/10] chore: update CHANGELOG.md and bump version to 0.5.0-alpha, highlighting analytics chart improvements and new export functionality --- CHANGELOG.md | 21 +-- components/dashboard/Chart.tsx | 244 +++++++++++++++++++++++++++------ package-lock.json | 11 +- package.json | 3 +- 4 files changed, 227 insertions(+), 52 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e365824..9ac5c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,38 +4,41 @@ 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 + +### Changed + +- **Analytics chart improvements.** Clearer labels (including what the chart measures), 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. + ## [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 diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 04ade88..2725168 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -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, @@ -11,10 +11,11 @@ import { Tooltip, ResponsiveContainer, ReferenceLine, + Label, } from 'recharts' import type { TooltipProps } from 'recharts' import { formatNumber, formatDuration } from '@/lib/utils/format' -import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select } from '@ciphera-net/ui' +import { ArrowUpRightIcon, ArrowDownRightIcon, BarChartIcon, Select, Button, DownloadIcon } from '@ciphera-net/ui' import { Checkbox } from '@ciphera-net/ui' const COLORS = { @@ -67,6 +68,8 @@ 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 } type MetricType = 'pageviews' | 'visitors' | 'bounce_rate' | 'avg_duration' @@ -80,6 +83,7 @@ function ChartTooltip({ metricLabel, formatNumberFn, showComparison, + prevPeriodLabel, colors, }: { active?: boolean @@ -89,6 +93,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 +145,7 @@ function ChartTooltip({ {hasPrev && (
- vs {formatValue(prev as number)} prev + vs {formatValue(prev as number)} {prevPeriodLabel ? `(${prevPeriodLabel})` : 'prev'} {delta !== null && ( 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> + 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 ( + + + + ) +} + export default function Chart({ data, prevData, @@ -174,12 +253,35 @@ export default function Chart({ todayInterval, setTodayInterval, multiDayInterval, - setMultiDayInterval + setMultiDayInterval, + onExportChart, }: ChartProps) { const [metric, setMetric] = useState('visitors') const [showComparison, setShowComparison] = useState(false) + const chartContainerRef = useRef(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 +367,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 = prevStats ? 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,25 +396,33 @@ export default function Chart({ const dayTicks = interval === 'day' && chartData.length > 0 ? chartData.map((c) => c.date) : undefined return ( -
+
{/* Stats Header (Interactive Tabs) */}
{metrics.map((item) => ( + {/* Vertical Separator */}
- {data.length === 0 ? ( + {!hasData ? (

@@ -407,6 +549,14 @@ export default function Chart({

Try a different date range

+ ) : !hasAnyNonZero ? ( +
+ +

+ No {metricLabel.toLowerCase()} data for this period +

+

Try selecting another metric or date range

+
) : (
@@ -439,7 +589,14 @@ export default function Chart({ if (metric === 'avg_duration') return formatDuration(val) return formatAxisValue(val) }} - /> + > +
- {trendContext && item.trend !== null && ( -

{trendContext}

- )} +

{trendContext}

{hasData && (
@@ -516,7 +528,7 @@ export default function Chart({ {showComparison && prevPeriodLabel && ( @@ -558,9 +570,26 @@ export default function Chart({

Try selecting another metric or date range

) : ( -
- - +
+ {/* * Vertical Y-axis label (text reads bottom-to-top) */} +
+ + {metricLabel} + +
+
+ + @@ -584,19 +613,13 @@ export default function Chart({ tickLine={false} axisLine={false} domain={[0, 'auto']} + width={48} 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) }} - > - - + + +
)}
-- 2.49.1 From 4aefca7118cb2262ee2de7fb107a40431da8d668 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Feb 2026 20:49:09 +0100 Subject: [PATCH 03/10] feat: add "Updated X ago" display for realtime indicators and implement auto-refresh tick functionality --- app/share/[id]/page.tsx | 37 +++++++++++++++++++------- app/sites/[id]/page.tsx | 58 +++++++++++++++++++++++++++-------------- lib/utils/format.ts | 12 +++++++++ 3 files changed, 78 insertions(+), 29 deletions(-) diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 56359e8..087f558 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -3,6 +3,7 @@ import { 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 { formatUpdatedAgo } from '@/lib/utils/format' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay, Button } from '@ciphera-net/ui' @@ -53,6 +54,8 @@ export default function PublicDashboardPage() { // Previous period data const [prevStats, setPrevStats] = useState(undefined) const [prevDailyStats, setPrevDailyStats] = useState(undefined) + const [lastUpdatedAt, setLastUpdatedAt] = useState(null) + const [, setTick] = useState(0) const getPreviousDateRange = (start: string, end: string) => { const startDate = new Date(start) @@ -78,17 +81,23 @@ export default function PublicDashboardPage() { } } - // Auto-refresh interval (for realtime) + // * Auto-refresh interval: chart, KPIs, and realtime count update every 30 seconds useEffect(() => { const interval = setInterval(() => { - // Only refresh realtime count if we have data if (data && !isPasswordProtected) { + loadDashboard(true) loadRealtime() } - }, 30000) // 30 seconds + }, 30000) return () => clearInterval(interval) - }, [data, isPasswordProtected, dateRange, password]) + }, [data, isPasswordProtected, dateRange, todayInterval, multiDayInterval, password]) + + // * Tick every 5s to refresh "Updated X ago" display + useEffect(() => { + const interval = setInterval(() => setTick((t) => t + 1), 5000) + return () => clearInterval(interval) + }, []) useEffect(() => { loadDashboard() @@ -153,6 +162,7 @@ export default function PublicDashboardPage() { setData(dashboardData) setPrevStats(prevStatsData) setPrevDailyStats(prevDailyStatsData) + setLastUpdatedAt(Date.now()) setIsPasswordProtected(false) // Reset captcha @@ -283,15 +293,22 @@ export default function PublicDashboardPage() {
- {/* Realtime Indicator - Desktop */} -
- + {/* Realtime Indicator & Polling - Desktop */} +
+
+ - - + + {realtime_visitors} current visitors - + +
+ {lastUpdatedAt !== null && ( + + Updated {formatUpdatedAgo(lastUpdatedAt)} + + )}
diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 7d5a319..676f9d4 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -6,7 +6,7 @@ import { useParams, useRouter } from 'next/navigation' import { motion } from 'framer-motion' import { getSite, type Site } from '@/lib/api/sites' import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getCampaigns, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' -import { formatNumber, formatDuration, getDateRange } from '@/lib/utils/format' +import { formatNumber, formatDuration, formatUpdatedAgo, getDateRange } from '@/lib/utils/format' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay, Button } from '@ciphera-net/ui' @@ -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(null) + const [, setTick] = useState(0) // Load settings from localStorage useEffect(() => { @@ -130,11 +132,18 @@ export default function SiteDashboardPage() { loadData() } const interval = setInterval(() => { + loadData(true) loadRealtime() - }, 30000) // Update every 30 seconds + }, 30000) // * Chart, KPIs, and realtime count update every 30 seconds return () => clearInterval(interval) }, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded]) + // * Tick every 5s to refresh "Updated X ago" display + useEffect(() => { + const interval = setInterval(() => setTick((t) => t + 1), 5000) + return () => clearInterval(interval) + }, []) + const getPreviousDateRange = (start: string, end: string) => { const startDate = new Date(start) const endDate = new Date(end) @@ -159,9 +168,9 @@ export default function SiteDashboardPage() { } } - const loadData = async () => { + const loadData = 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,10 +209,13 @@ 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) } } @@ -247,19 +259,27 @@ export default function SiteDashboardPage() {

- {/* Realtime Indicator */} - +
+ {/* Realtime Indicator */} + + {/* Polling indicator */} + {lastUpdatedAt !== null && ( + + Updated {formatUpdatedAgo(lastUpdatedAt)} + + )} +
diff --git a/lib/utils/format.ts b/lib/utils/format.ts index fb0a200..485efa6 100644 --- a/lib/utils/format.ts +++ b/lib/utils/format.ts @@ -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") */ -- 2.49.1 From 928d1571bd17605d66d3701a31cb3fc7711318cd Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Feb 2026 20:52:06 +0100 Subject: [PATCH 04/10] fix: adjust layout and styling in Chart component for improved visual consistency --- components/dashboard/Chart.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index f065aed..053d0fe 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -570,11 +570,11 @@ export default function Chart({

Try selecting another metric or date range

) : ( -
+
{/* * Vertical Y-axis label (text reads bottom-to-top) */}
- + @@ -613,7 +613,7 @@ export default function Chart({ tickLine={false} axisLine={false} domain={[0, 'auto']} - width={48} + width={36} tickFormatter={(val) => { if (metric === 'bounce_rate') return `${val}%` if (metric === 'avg_duration') return formatAxisDuration(val) -- 2.49.1 From 9a2b3da8fd170730e6543ccbd99da589390a86b1 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Feb 2026 21:19:34 +0100 Subject: [PATCH 05/10] fix: refine layout and styling in Chart component for better visual alignment and consistency --- components/dashboard/Chart.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 053d0fe..9a5e559 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -570,11 +570,11 @@ export default function Chart({

Try selecting another metric or date range

) : ( -
+
{/* * Vertical Y-axis label (text reads bottom-to-top) */}
- + @@ -613,7 +613,7 @@ export default function Chart({ tickLine={false} axisLine={false} domain={[0, 'auto']} - width={36} + width={28} tickFormatter={(val) => { if (metric === 'bounce_rate') return `${val}%` if (metric === 'avg_duration') return formatAxisDuration(val) -- 2.49.1 From d25910ffc372fd06acfacbb6c805905cd98cb019 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Feb 2026 21:27:23 +0100 Subject: [PATCH 06/10] fix: update layout and styling in Chart component for improved readability and alignment --- components/dashboard/Chart.tsx | 25 ++++++------------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 9a5e559..b0c74ff 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -570,26 +570,13 @@ export default function Chart({

Try selecting another metric or date range

) : ( -
- {/* * Vertical Y-axis label (text reads bottom-to-top) */} -
- - {metricLabel} - +
+
+ {metricLabel}
-
+
- + @@ -613,7 +600,7 @@ export default function Chart({ tickLine={false} axisLine={false} domain={[0, 'auto']} - width={28} + width={24} tickFormatter={(val) => { if (metric === 'bounce_rate') return `${val}%` if (metric === 'avg_duration') return formatAxisDuration(val) -- 2.49.1 From 9b95ead6bad26cb7bf6f5679409d54f139fa559d Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Feb 2026 08:24:55 +0100 Subject: [PATCH 07/10] feat: implement last updated timestamp display in dashboard components for improved data freshness indication --- app/share/[id]/page.tsx | 27 +++++++++---------------- app/sites/[id]/page.tsx | 37 ++++++++++++++-------------------- components/dashboard/Chart.tsx | 16 +++++++++++++-- 3 files changed, 39 insertions(+), 41 deletions(-) diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 087f558..dae6587 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -3,7 +3,6 @@ import { 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 { formatUpdatedAgo } from '@/lib/utils/format' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay, Button } from '@ciphera-net/ui' @@ -293,22 +292,15 @@ export default function PublicDashboardPage() {
- {/* Realtime Indicator & Polling - Desktop */} -
-
- - - - - - {realtime_visitors} current visitors - -
- {lastUpdatedAt !== null && ( - - Updated {formatUpdatedAgo(lastUpdatedAt)} - - )} + {/* Realtime Indicator - Desktop */} +
+ + + + + + {realtime_visitors} current visitors +
@@ -388,6 +380,7 @@ export default function PublicDashboardPage() { setTodayInterval={setTodayInterval} multiDayInterval={multiDayInterval} setMultiDayInterval={setMultiDayInterval} + lastUpdatedAt={lastUpdatedAt} />
diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index 676f9d4..ac9bd4c 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -6,7 +6,7 @@ import { useParams, useRouter } from 'next/navigation' import { motion } from 'framer-motion' import { getSite, type Site } from '@/lib/api/sites' import { getStats, getRealtime, getDailyStats, getTopPages, getTopReferrers, getCountries, getCities, getRegions, getBrowsers, getOS, getDevices, getScreenResolutions, getEntryPages, getExitPages, getDashboard, getCampaigns, getPerformanceByPage, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats' -import { formatNumber, formatDuration, formatUpdatedAgo, getDateRange } from '@/lib/utils/format' +import { formatNumber, formatDuration, getDateRange } from '@/lib/utils/format' import { toast } from '@ciphera-net/ui' import { getAuthErrorMessage } from '@/lib/utils/authErrors' import { LoadingOverlay, Button } from '@ciphera-net/ui' @@ -259,27 +259,19 @@ export default function SiteDashboardPage() {

-
- {/* Realtime Indicator */} - - {/* Polling indicator */} - {lastUpdatedAt !== null && ( - - Updated {formatUpdatedAgo(lastUpdatedAt)} - - )} -
+ {/* Realtime Indicator */} +
@@ -379,6 +371,7 @@ export default function SiteDashboardPage() { setTodayInterval={setTodayInterval} multiDayInterval={multiDayInterval} setMultiDayInterval={setMultiDayInterval} + lastUpdatedAt={lastUpdatedAt} />
diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index b0c74ff..b94e468 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -13,7 +13,7 @@ import { ReferenceLine, } from 'recharts' import type { TooltipProps } from 'recharts' -import { formatNumber, formatDuration } from '@/lib/utils/format' +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' @@ -69,6 +69,8 @@ interface ChartProps { 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' @@ -263,6 +265,7 @@ export default function Chart({ multiDayInterval, setMultiDayInterval, onExportChart, + lastUpdatedAt, }: ChartProps) { const [metric, setMetric] = useState('visitors') const [showComparison, setShowComparison] = useState(false) @@ -406,10 +409,19 @@ export default function Chart({ return (
+ {/* * Subtle live/updated indicator in bottom-right corner */} + {lastUpdatedAt != null && ( +
+ Live · {formatUpdatedAgo(lastUpdatedAt)} +
+ )} {/* Stats Header (Interactive Tabs) */}
{metrics.map((item) => ( -- 2.49.1 From fc56cbd66158c36794995d77651783ba9c053c93 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Feb 2026 08:30:58 +0100 Subject: [PATCH 08/10] refactor: update tick interval for real-time display in dashboard components and enhance live indicator in Chart component --- app/share/[id]/page.tsx | 4 ++-- app/sites/[id]/page.tsx | 4 ++-- components/dashboard/Chart.tsx | 6 +++++- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index dae6587..4ea7408 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -92,9 +92,9 @@ export default function PublicDashboardPage() { return () => clearInterval(interval) }, [data, isPasswordProtected, dateRange, todayInterval, multiDayInterval, password]) - // * Tick every 5s to refresh "Updated X ago" display + // * Tick every 1s so "Live · Xs ago" counts in real time useEffect(() => { - const interval = setInterval(() => setTick((t) => t + 1), 5000) + const interval = setInterval(() => setTick((t) => t + 1), 1000) return () => clearInterval(interval) }, []) diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index ac9bd4c..bd191cf 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -138,9 +138,9 @@ export default function SiteDashboardPage() { return () => clearInterval(interval) }, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded]) - // * Tick every 5s to refresh "Updated X ago" display + // * Tick every 1s so "Live · Xs ago" counts in real time useEffect(() => { - const interval = setInterval(() => setTick((t) => t + 1), 5000) + const interval = setInterval(() => setTick((t) => t + 1), 1000) return () => clearInterval(interval) }, []) diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index b94e468..85c104b 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -416,9 +416,13 @@ export default function Chart({ {/* * Subtle live/updated indicator in bottom-right corner */} {lastUpdatedAt != null && (
+ + + + Live · {formatUpdatedAgo(lastUpdatedAt)}
)} -- 2.49.1 From 10b728000d04b5e9b1fda70aaa4aa6d2931721ea Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Feb 2026 08:36:10 +0100 Subject: [PATCH 09/10] feat: add live chart and KPIs with real-time updates, and enhance polling indicator for data freshness in CHANGELOG.md --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ac5c0e..737ec67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.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 (including what the chart measures), 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. +- **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 -- 2.49.1 From 213e33794001cbc5cac3b6e09b1b784f371aee92 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Thu, 12 Feb 2026 08:50:10 +0100 Subject: [PATCH 10/10] refactor: optimize data loading and auto-refresh logic in dashboard components, and enhance performance with useCallback for better efficiency --- app/share/[id]/page.tsx | 41 +++++++++++++++++----------------- app/sites/[id]/page.tsx | 37 ++++++++++-------------------- components/dashboard/Chart.tsx | 4 ++-- 3 files changed, 34 insertions(+), 48 deletions(-) diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index 4ea7408..912f158 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -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' @@ -80,29 +80,13 @@ export default function PublicDashboardPage() { } } - // * Auto-refresh interval: chart, KPIs, and realtime count update every 30 seconds - useEffect(() => { - const interval = setInterval(() => { - if (data && !isPasswordProtected) { - loadDashboard(true) - loadRealtime() - } - }, 30000) - - return () => clearInterval(interval) - }, [data, isPasswordProtected, dateRange, todayInterval, multiDayInterval, password]) - // * Tick every 1s so "Live · Xs ago" counts in real time useEffect(() => { const interval = setInterval(() => setTick((t) => t + 1), 1000) return () => clearInterval(interval) }, []) - useEffect(() => { - loadDashboard() - }, [siteId, dateRange, todayInterval, multiDayInterval]) - - const loadRealtime = async () => { + const loadRealtime = useCallback(async () => { try { const auth = { password, @@ -122,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) @@ -186,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() diff --git a/app/sites/[id]/page.tsx b/app/sites/[id]/page.tsx index bd191cf..85710cb 100644 --- a/app/sites/[id]/page.tsx +++ b/app/sites/[id]/page.tsx @@ -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' @@ -128,15 +128,13 @@ 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) // * Chart, KPIs, and realtime count update every 30 seconds + }, 30000) return () => clearInterval(interval) - }, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded]) + }, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded, loadData, loadRealtime]) // * Tick every 1s so "Live · Xs ago" counts in real time useEffect(() => { @@ -144,31 +142,20 @@ export default function SiteDashboardPage() { return () => clearInterval(interval) }, []) - const getPreviousDateRange = (start: string, end: string) => { + 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 (silent = false) => { + const loadData = useCallback(async (silent = false) => { try { if (!silent) setLoading(true) const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval @@ -217,16 +204,16 @@ export default function SiteDashboardPage() { } finally { 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 diff --git a/components/dashboard/Chart.tsx b/components/dashboard/Chart.tsx index 85c104b..89ff932 100644 --- a/components/dashboard/Chart.tsx +++ b/components/dashboard/Chart.tsx @@ -650,8 +650,8 @@ export default function Chart({ strokeDasharray="4 4" strokeOpacity={0.7} label={{ - value: `Avg: ${metric === 'bounce_rate' ? `${Math.round(avg)}%` : metric === 'avg_duration' ? formatDuration(avg) : formatAxisValue(avg)}`, - position: 'right', + value: `Avg: ${metric === 'bounce_rate' ? `${Math.round(avg)}%` : metric === 'avg_duration' ? formatAxisDuration(avg) : formatAxisValue(avg)}`, + position: 'insideTopRight', fill: colors.axis, fontSize: 11, }} -- 2.49.1