feat: add "Updated X ago" display for realtime indicators and implement auto-refresh tick functionality
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useParams, useSearchParams, useRouter } from 'next/navigation'
|
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 { 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 { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||||
import { LoadingOverlay, Button } from '@ciphera-net/ui'
|
import { LoadingOverlay, Button } from '@ciphera-net/ui'
|
||||||
@@ -53,6 +54,8 @@ export default function PublicDashboardPage() {
|
|||||||
// Previous period data
|
// Previous period data
|
||||||
const [prevStats, setPrevStats] = useState<Stats | undefined>(undefined)
|
const [prevStats, setPrevStats] = useState<Stats | undefined>(undefined)
|
||||||
const [prevDailyStats, setPrevDailyStats] = useState<DailyStat[] | 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 getPreviousDateRange = (start: string, end: string) => {
|
||||||
const startDate = new Date(start)
|
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(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
// Only refresh realtime count if we have data
|
|
||||||
if (data && !isPasswordProtected) {
|
if (data && !isPasswordProtected) {
|
||||||
|
loadDashboard(true)
|
||||||
loadRealtime()
|
loadRealtime()
|
||||||
}
|
}
|
||||||
}, 30000) // 30 seconds
|
}, 30000)
|
||||||
|
|
||||||
return () => clearInterval(interval)
|
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(() => {
|
useEffect(() => {
|
||||||
loadDashboard()
|
loadDashboard()
|
||||||
@@ -153,6 +162,7 @@ export default function PublicDashboardPage() {
|
|||||||
setData(dashboardData)
|
setData(dashboardData)
|
||||||
setPrevStats(prevStatsData)
|
setPrevStats(prevStatsData)
|
||||||
setPrevDailyStats(prevDailyStatsData)
|
setPrevDailyStats(prevDailyStatsData)
|
||||||
|
setLastUpdatedAt(Date.now())
|
||||||
|
|
||||||
setIsPasswordProtected(false)
|
setIsPasswordProtected(false)
|
||||||
// Reset captcha
|
// Reset captcha
|
||||||
@@ -283,15 +293,22 @@ export default function PublicDashboardPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Realtime Indicator - Desktop */}
|
{/* Realtime Indicator & Polling - 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">
|
<div className="hidden md:flex items-center gap-3 self-end mb-1">
|
||||||
<span className="relative flex h-2 w-2">
|
<div className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20">
|
||||||
|
<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="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 className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm font-medium text-green-700 dark:text-green-400">
|
<span className="text-sm font-medium text-green-700 dark:text-green-400">
|
||||||
{realtime_visitors} current visitors
|
{realtime_visitors} current visitors
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
|
{lastUpdatedAt !== null && (
|
||||||
|
<span className="text-xs text-neutral-500 dark:text-neutral-400" title="Data refreshes every 30 seconds">
|
||||||
|
Updated {formatUpdatedAgo(lastUpdatedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useParams, useRouter } from 'next/navigation'
|
|||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { getSite, type Site } from '@/lib/api/sites'
|
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 { 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 { toast } from '@ciphera-net/ui'
|
||||||
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
import { getAuthErrorMessage } from '@/lib/utils/authErrors'
|
||||||
import { LoadingOverlay, Button } from '@ciphera-net/ui'
|
import { LoadingOverlay, Button } from '@ciphera-net/ui'
|
||||||
@@ -57,6 +57,8 @@ export default function SiteDashboardPage() {
|
|||||||
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
const [todayInterval, setTodayInterval] = useState<'minute' | 'hour'>('hour')
|
||||||
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
|
const [multiDayInterval, setMultiDayInterval] = useState<'hour' | 'day'>('day')
|
||||||
const [isSettingsLoaded, setIsSettingsLoaded] = useState(false)
|
const [isSettingsLoaded, setIsSettingsLoaded] = useState(false)
|
||||||
|
const [lastUpdatedAt, setLastUpdatedAt] = useState<number | null>(null)
|
||||||
|
const [, setTick] = useState(0)
|
||||||
|
|
||||||
// Load settings from localStorage
|
// Load settings from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -130,11 +132,18 @@ export default function SiteDashboardPage() {
|
|||||||
loadData()
|
loadData()
|
||||||
}
|
}
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
|
loadData(true)
|
||||||
loadRealtime()
|
loadRealtime()
|
||||||
}, 30000) // Update every 30 seconds
|
}, 30000) // * Chart, KPIs, and realtime count update every 30 seconds
|
||||||
return () => clearInterval(interval)
|
return () => clearInterval(interval)
|
||||||
}, [siteId, dateRange, todayInterval, multiDayInterval, isSettingsLoaded])
|
}, [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 getPreviousDateRange = (start: string, end: string) => {
|
||||||
const startDate = new Date(start)
|
const startDate = new Date(start)
|
||||||
const endDate = new Date(end)
|
const endDate = new Date(end)
|
||||||
@@ -159,9 +168,9 @@ export default function SiteDashboardPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async (silent = false) => {
|
||||||
try {
|
try {
|
||||||
setLoading(true)
|
if (!silent) setLoading(true)
|
||||||
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
const interval = dateRange.start === dateRange.end ? todayInterval : multiDayInterval
|
||||||
|
|
||||||
const [data, prevStatsData, prevDailyStatsData, campaignsData] = await Promise.all([
|
const [data, prevStatsData, prevDailyStatsData, campaignsData] = await Promise.all([
|
||||||
@@ -200,10 +209,13 @@ export default function SiteDashboardPage() {
|
|||||||
setPerformanceByPage(data.performance_by_page ?? null)
|
setPerformanceByPage(data.performance_by_page ?? null)
|
||||||
setGoalCounts(Array.isArray(data.goal_counts) ? data.goal_counts : [])
|
setGoalCounts(Array.isArray(data.goal_counts) ? data.goal_counts : [])
|
||||||
setCampaigns(Array.isArray(campaignsData) ? campaignsData : [])
|
setCampaigns(Array.isArray(campaignsData) ? campaignsData : [])
|
||||||
|
setLastUpdatedAt(Date.now())
|
||||||
} catch (error: unknown) {
|
} 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 {
|
} finally {
|
||||||
setLoading(false)
|
if (!silent) setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,19 +259,27 @@ export default function SiteDashboardPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Realtime Indicator */}
|
<div className="flex items-center gap-3">
|
||||||
<button
|
{/* Realtime Indicator */}
|
||||||
onClick={() => router.push(`/sites/${siteId}/realtime`)}
|
<button
|
||||||
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 hover:bg-green-500/20 transition-colors cursor-pointer"
|
onClick={() => router.push(`/sites/${siteId}/realtime`)}
|
||||||
>
|
className="flex items-center gap-2 px-3 py-1 bg-green-500/10 rounded-full border border-green-500/20 hover:bg-green-500/20 transition-colors cursor-pointer"
|
||||||
<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 flex h-2 w-2">
|
||||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-500 opacity-75"></span>
|
||||||
</span>
|
<span className="relative inline-flex rounded-full h-2 w-2 bg-green-500"></span>
|
||||||
<span className="text-sm font-medium text-green-700 dark:text-green-400">
|
</span>
|
||||||
{realtime} current visitors
|
<span className="text-sm font-medium text-green-700 dark:text-green-400">
|
||||||
</span>
|
{realtime} current visitors
|
||||||
</button>
|
</span>
|
||||||
|
</button>
|
||||||
|
{/* Polling indicator */}
|
||||||
|
{lastUpdatedAt !== null && (
|
||||||
|
<span className="text-xs text-neutral-500 dark:text-neutral-400" title="Data refreshes every 30 seconds">
|
||||||
|
Updated {formatUpdatedAgo(lastUpdatedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -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")
|
* Format relative time (e.g., "2 hours ago")
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user