From c623ae1e9b9cb9c437f142a9bbdfce5f0becd975 Mon Sep 17 00:00:00 2001 From: Usman Baig Date: Wed, 11 Feb 2026 20:04:33 +0100 Subject: [PATCH] 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) }} - /> + > +