Merge branch 'main' into staging
This commit is contained in:
@@ -22,6 +22,36 @@ export function getSignupUrl(redirectPath = '/auth/callback') {
|
||||
return `${AUTH_URL}/signup?client_id=pulse-app&redirect_uri=${redirectUri}&response_type=code`
|
||||
}
|
||||
|
||||
// * ============================================================================
|
||||
// * CSRF Token Handling
|
||||
// * ============================================================================
|
||||
|
||||
/**
|
||||
* Get CSRF token from the csrf_token cookie (non-httpOnly)
|
||||
* This is needed for state-changing requests to the Auth API
|
||||
*/
|
||||
function getCSRFToken(): string | null {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
const cookies = document.cookie.split(';')
|
||||
for (const cookie of cookies) {
|
||||
const [name, value] = cookie.trim().split('=')
|
||||
if (name === 'csrf_token') {
|
||||
return decodeURIComponent(value)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a request method requires CSRF protection
|
||||
* State-changing methods (POST, PUT, DELETE, PATCH) need CSRF tokens
|
||||
*/
|
||||
function isStateChangingMethod(method: string): boolean {
|
||||
const stateChangingMethods = ['POST', 'PUT', 'DELETE', 'PATCH']
|
||||
return stateChangingMethods.includes(method.toUpperCase())
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
data?: Record<string, unknown>
|
||||
@@ -58,50 +88,144 @@ function onRefreshFailed(err: unknown) {
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
// * ============================================================================
|
||||
// * Request Deduplication & Caching
|
||||
// * ============================================================================
|
||||
|
||||
/** Cache TTL in milliseconds (2 seconds) */
|
||||
const CACHE_TTL_MS = 2_000
|
||||
|
||||
/** Stores in-flight requests for deduplication */
|
||||
interface PendingRequest {
|
||||
promise: Promise<unknown>
|
||||
timestamp: number
|
||||
}
|
||||
const pendingRequests = new Map<string, PendingRequest>()
|
||||
|
||||
/** Stores cached responses */
|
||||
interface CachedResponse {
|
||||
data: unknown
|
||||
timestamp: number
|
||||
}
|
||||
const responseCache = new Map<string, CachedResponse>()
|
||||
|
||||
/**
|
||||
* Base API client with error handling
|
||||
* Generate a unique key for a request based on endpoint and options
|
||||
*/
|
||||
function getRequestKey(endpoint: string, options: RequestInit): string {
|
||||
const method = options.method || 'GET'
|
||||
const body = options.body || ''
|
||||
return `${method}:${endpoint}:${body}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired entries from pending requests and response cache
|
||||
*/
|
||||
function cleanupExpiredEntries(): void {
|
||||
const now = Date.now()
|
||||
|
||||
// * Clean up stale pending requests (older than 30 seconds)
|
||||
for (const [key, pending] of pendingRequests.entries()) {
|
||||
if (now - pending.timestamp > 30_000) {
|
||||
pendingRequests.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
// * Clean up stale cached responses (older than CACHE_TTL_MS)
|
||||
for (const [key, cached] of responseCache.entries()) {
|
||||
if (now - cached.timestamp > CACHE_TTL_MS) {
|
||||
responseCache.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Base API client with error handling, request deduplication, and short-term caching
|
||||
*/
|
||||
async function apiRequest<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<T> {
|
||||
// * Skip deduplication for non-GET requests (mutations should always execute)
|
||||
const method = options.method || 'GET'
|
||||
const shouldDedupe = method === 'GET'
|
||||
|
||||
if (shouldDedupe) {
|
||||
// * Clean up expired entries periodically
|
||||
if (pendingRequests.size > 100 || responseCache.size > 100) {
|
||||
cleanupExpiredEntries()
|
||||
}
|
||||
|
||||
const requestKey = getRequestKey(endpoint, options)
|
||||
|
||||
// * Check if we have a recent cached response (within 2 seconds)
|
||||
const cached = responseCache.get(requestKey)
|
||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
||||
return cached.data as T
|
||||
}
|
||||
|
||||
// * Check if there's an identical request in flight
|
||||
const pending = pendingRequests.get(requestKey)
|
||||
if (pending && Date.now() - pending.timestamp < 30000) {
|
||||
return pending.promise as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
// * Determine base URL
|
||||
const isAuthRequest = endpoint.startsWith('/auth')
|
||||
const baseUrl = isAuthRequest ? AUTH_API_URL : API_URL
|
||||
|
||||
|
||||
// * Handle legacy endpoints that already include /api/ prefix
|
||||
const url = endpoint.startsWith('/api/')
|
||||
const url = endpoint.startsWith('/api/')
|
||||
? `${baseUrl}${endpoint}`
|
||||
: `${baseUrl}/api/v1${endpoint}`
|
||||
|
||||
const headers: HeadersInit = {
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
}
|
||||
|
||||
// * Merge any additional headers from options
|
||||
if (options.headers) {
|
||||
const additionalHeaders = options.headers as Record<string, string>
|
||||
Object.entries(additionalHeaders).forEach(([key, value]) => {
|
||||
headers[key] = value
|
||||
})
|
||||
}
|
||||
|
||||
// * We rely on HttpOnly cookies, so no manual Authorization header injection.
|
||||
// * We MUST set credentials: 'include' for the browser to send cookies cross-origin (or same-site).
|
||||
|
||||
// * Add CSRF token for state-changing requests to Auth API
|
||||
// * Auth API uses Double Submit Cookie pattern for CSRF protection
|
||||
if (isAuthRequest && isStateChangingMethod(method)) {
|
||||
const csrfToken = getCSRFToken()
|
||||
if (csrfToken) {
|
||||
headers['X-CSRF-Token'] = csrfToken
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
||||
const signal = options.signal ?? controller.signal
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include', // * IMPORTANT: Send cookies
|
||||
signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId)
|
||||
if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) {
|
||||
throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
|
||||
// * Create the request promise
|
||||
const requestPromise = (async (): Promise<T> => {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
credentials: 'include', // * IMPORTANT: Send cookies
|
||||
signal,
|
||||
})
|
||||
clearTimeout(timeoutId)
|
||||
} catch (e) {
|
||||
clearTimeout(timeoutId)
|
||||
if (e instanceof Error && (e.name === 'AbortError' || e.name === 'TypeError')) {
|
||||
throw new ApiError(AUTH_ERROR_MESSAGES.NETWORK, 0)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
@@ -182,6 +306,38 @@ async function apiRequest<T>(
|
||||
}
|
||||
|
||||
return response.json()
|
||||
})()
|
||||
|
||||
// * For GET requests, track the promise for deduplication and cache the result
|
||||
if (shouldDedupe) {
|
||||
const requestKey = getRequestKey(endpoint, options)
|
||||
|
||||
// * Store in pending requests
|
||||
pendingRequests.set(requestKey, {
|
||||
promise: requestPromise as Promise<unknown>,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
|
||||
// * Clean up pending request and cache the result when done
|
||||
requestPromise
|
||||
.then((data) => {
|
||||
// * Cache successful response
|
||||
responseCache.set(requestKey, {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
})
|
||||
// * Remove from pending
|
||||
pendingRequests.delete(requestKey)
|
||||
return data
|
||||
})
|
||||
.catch((error) => {
|
||||
// * Remove from pending on error too
|
||||
pendingRequests.delete(requestKey)
|
||||
throw error
|
||||
})
|
||||
}
|
||||
|
||||
return requestPromise
|
||||
}
|
||||
|
||||
export const authFetch = apiRequest
|
||||
|
||||
261
lib/api/stats.ts
261
lib/api/stats.ts
@@ -332,11 +332,11 @@ export async function getDashboard(siteId: string, startDate?: string, endDate?:
|
||||
}
|
||||
|
||||
export async function getPublicDashboard(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
interval?: string,
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
interval?: string,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardData> {
|
||||
@@ -344,9 +344,256 @@ export async function getPublicDashboard(
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
|
||||
|
||||
appendAuthParams(params, { password, captcha })
|
||||
|
||||
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardData>(`/public/sites/${siteId}/dashboard?${params.toString()}`)
|
||||
}
|
||||
|
||||
// * ============================================================================
|
||||
// * Focused Dashboard Endpoints (Fix 4.2: Efficient Data Transfer)
|
||||
// * These split the massive dashboard payload into smaller, focused chunks
|
||||
// * ============================================================================
|
||||
|
||||
export interface DashboardOverviewData {
|
||||
site: Site
|
||||
stats: Stats
|
||||
realtime_visitors: number
|
||||
daily_stats: DailyStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardOverview(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
interval?: string
|
||||
): Promise<DashboardOverviewData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
return apiRequest<DashboardOverviewData>(`/sites/${siteId}/dashboard/overview?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardOverview(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
interval?: string,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardOverviewData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
if (interval) params.append('interval', interval)
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardOverviewData>(`/public/sites/${siteId}/dashboard/overview?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardPagesData {
|
||||
top_pages: TopPage[]
|
||||
entry_pages: TopPage[]
|
||||
exit_pages: TopPage[]
|
||||
}
|
||||
|
||||
export async function getDashboardPages(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardPagesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardPagesData>(`/sites/${siteId}/dashboard/pages?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardPages(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPagesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardPagesData>(`/public/sites/${siteId}/dashboard/pages?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardLocationsData {
|
||||
countries: CountryStat[]
|
||||
cities: CityStat[]
|
||||
regions: RegionStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardLocations(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
countryLimit = 250
|
||||
): Promise<DashboardLocationsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
params.append('country_limit', countryLimit.toString())
|
||||
return apiRequest<DashboardLocationsData>(`/sites/${siteId}/dashboard/locations?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardLocations(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
countryLimit = 250,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardLocationsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
params.append('country_limit', countryLimit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardLocationsData>(`/public/sites/${siteId}/dashboard/locations?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardDevicesData {
|
||||
browsers: BrowserStat[]
|
||||
os: OSStat[]
|
||||
devices: DeviceStat[]
|
||||
screen_resolutions: ScreenResolutionStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardDevices(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardDevicesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardDevicesData>(`/sites/${siteId}/dashboard/devices?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardDevices(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardDevicesData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardDevicesData>(`/public/sites/${siteId}/dashboard/devices?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardReferrersData {
|
||||
top_referrers: TopReferrer[]
|
||||
}
|
||||
|
||||
export async function getDashboardReferrers(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardReferrersData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardReferrersData>(`/sites/${siteId}/dashboard/referrers?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardReferrers(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardReferrersData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardReferrersData>(`/public/sites/${siteId}/dashboard/referrers?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardPerformanceData {
|
||||
performance?: PerformanceStats
|
||||
performance_by_page?: PerformanceByPageStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardPerformance(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<DashboardPerformanceData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
return apiRequest<DashboardPerformanceData>(`/sites/${siteId}/dashboard/performance?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardPerformance(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardPerformanceData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardPerformanceData>(`/public/sites/${siteId}/dashboard/performance?${params.toString()}`)
|
||||
}
|
||||
|
||||
export interface DashboardGoalsData {
|
||||
goal_counts: GoalCountStat[]
|
||||
}
|
||||
|
||||
export async function getDashboardGoals(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10
|
||||
): Promise<DashboardGoalsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
return apiRequest<DashboardGoalsData>(`/sites/${siteId}/dashboard/goals?${params.toString()}`)
|
||||
}
|
||||
|
||||
export async function getPublicDashboardGoals(
|
||||
siteId: string,
|
||||
startDate?: string,
|
||||
endDate?: string,
|
||||
limit = 10,
|
||||
password?: string,
|
||||
captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
|
||||
): Promise<DashboardGoalsData> {
|
||||
const params = new URLSearchParams()
|
||||
if (startDate) params.append('start_date', startDate)
|
||||
if (endDate) params.append('end_date', endDate)
|
||||
params.append('limit', limit.toString())
|
||||
appendAuthParams(params, { password, captcha })
|
||||
return apiRequest<DashboardGoalsData>(`/public/sites/${siteId}/dashboard/goals?${params.toString()}`)
|
||||
}
|
||||
|
||||
128
lib/hooks/useVisibilityPolling.ts
Normal file
128
lib/hooks/useVisibilityPolling.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// * Custom hook for visibility-aware polling
|
||||
// * Pauses polling when tab is not visible, resumes when visible
|
||||
// * Reduces server load when users aren't actively viewing the dashboard
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
|
||||
interface UseVisibilityPollingOptions {
|
||||
// * Polling interval when tab is visible (in milliseconds)
|
||||
visibleInterval: number
|
||||
// * Polling interval when tab is hidden (in milliseconds, or null to pause)
|
||||
hiddenInterval: number | null
|
||||
}
|
||||
|
||||
interface UseVisibilityPollingReturn {
|
||||
// * Whether polling is currently active
|
||||
isPolling: boolean
|
||||
// * Time since last poll
|
||||
lastPollTime: number | null
|
||||
// * Force a poll immediately
|
||||
triggerPoll: () => void
|
||||
}
|
||||
|
||||
export function useVisibilityPolling(
|
||||
callback: () => void | Promise<void>,
|
||||
options: UseVisibilityPollingOptions
|
||||
): UseVisibilityPollingReturn {
|
||||
const { visibleInterval, hiddenInterval } = options
|
||||
const [isPolling, setIsPolling] = useState(false)
|
||||
const [lastPollTime, setLastPollTime] = useState<number | null>(null)
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const callbackRef = useRef(callback)
|
||||
|
||||
// * Keep callback reference up to date
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback
|
||||
}, [callback])
|
||||
|
||||
// * Get current polling interval based on visibility
|
||||
const getInterval = useCallback((): number | null => {
|
||||
if (typeof document === 'undefined') return null
|
||||
|
||||
const isVisible = document.visibilityState === 'visible'
|
||||
if (isVisible) {
|
||||
return visibleInterval
|
||||
}
|
||||
return hiddenInterval
|
||||
}, [visibleInterval, hiddenInterval])
|
||||
|
||||
// * Start polling with current interval
|
||||
const startPolling = useCallback(() => {
|
||||
const interval = getInterval()
|
||||
if (interval === null) {
|
||||
setIsPolling(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsPolling(true)
|
||||
|
||||
// * Clear any existing interval
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
}
|
||||
|
||||
// * Set up new interval
|
||||
intervalRef.current = setInterval(() => {
|
||||
callbackRef.current()
|
||||
setLastPollTime(Date.now())
|
||||
}, interval)
|
||||
}, [getInterval])
|
||||
|
||||
// * Stop polling
|
||||
const stopPolling = useCallback(() => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current)
|
||||
intervalRef.current = null
|
||||
}
|
||||
setIsPolling(false)
|
||||
}, [])
|
||||
|
||||
// * Trigger immediate poll
|
||||
const triggerPoll = useCallback(() => {
|
||||
callbackRef.current()
|
||||
setLastPollTime(Date.now())
|
||||
|
||||
// * Restart polling timer
|
||||
startPolling()
|
||||
}, [startPolling])
|
||||
|
||||
// * Handle visibility changes
|
||||
useEffect(() => {
|
||||
if (typeof document === 'undefined') return
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
// * Tab became visible - resume polling with visible interval
|
||||
startPolling()
|
||||
// * Trigger immediate poll to get fresh data
|
||||
triggerPoll()
|
||||
} else {
|
||||
// * Tab hidden - switch to hidden interval or pause
|
||||
const interval = getInterval()
|
||||
if (interval === null) {
|
||||
stopPolling()
|
||||
} else {
|
||||
// * Restart with hidden interval
|
||||
startPolling()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// * Listen for visibility changes
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
// * Start polling initially
|
||||
startPolling()
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopPolling()
|
||||
}
|
||||
}, [startPolling, stopPolling, triggerPoll, getInterval])
|
||||
|
||||
return {
|
||||
isPolling,
|
||||
lastPollTime,
|
||||
triggerPoll,
|
||||
}
|
||||
}
|
||||
234
lib/swr/dashboard.ts
Normal file
234
lib/swr/dashboard.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
// * SWR configuration for dashboard data fetching
|
||||
// * Implements stale-while-revalidate pattern for efficient data updates
|
||||
|
||||
import useSWR from 'swr'
|
||||
import {
|
||||
getDashboard,
|
||||
getDashboardOverview,
|
||||
getDashboardPages,
|
||||
getDashboardLocations,
|
||||
getDashboardDevices,
|
||||
getDashboardReferrers,
|
||||
getDashboardPerformance,
|
||||
getDashboardGoals,
|
||||
getRealtime,
|
||||
getStats,
|
||||
getDailyStats,
|
||||
} from '@/lib/api/stats'
|
||||
import { getSite } from '@/lib/api/sites'
|
||||
import type { Site } from '@/lib/api/sites'
|
||||
import type {
|
||||
Stats,
|
||||
DailyStat,
|
||||
DashboardOverviewData,
|
||||
DashboardPagesData,
|
||||
DashboardLocationsData,
|
||||
DashboardDevicesData,
|
||||
DashboardReferrersData,
|
||||
DashboardPerformanceData,
|
||||
DashboardGoalsData,
|
||||
} from '@/lib/api/stats'
|
||||
|
||||
// * SWR fetcher functions
|
||||
const fetchers = {
|
||||
site: (siteId: string) => getSite(siteId),
|
||||
dashboard: (siteId: string, start: string, end: string) => getDashboard(siteId, start, end),
|
||||
dashboardOverview: (siteId: string, start: string, end: string) => getDashboardOverview(siteId, start, end),
|
||||
dashboardPages: (siteId: string, start: string, end: string) => getDashboardPages(siteId, start, end),
|
||||
dashboardLocations: (siteId: string, start: string, end: string) => getDashboardLocations(siteId, start, end),
|
||||
dashboardDevices: (siteId: string, start: string, end: string) => getDashboardDevices(siteId, start, end),
|
||||
dashboardReferrers: (siteId: string, start: string, end: string) => getDashboardReferrers(siteId, start, end),
|
||||
dashboardPerformance: (siteId: string, start: string, end: string) => getDashboardPerformance(siteId, start, end),
|
||||
dashboardGoals: (siteId: string, start: string, end: string) => getDashboardGoals(siteId, start, end),
|
||||
stats: (siteId: string, start: string, end: string) => getStats(siteId, start, end),
|
||||
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
|
||||
getDailyStats(siteId, start, end, interval),
|
||||
realtime: (siteId: string) => getRealtime(siteId),
|
||||
}
|
||||
|
||||
// * Standard SWR config for dashboard data
|
||||
const dashboardSWRConfig = {
|
||||
// * Keep stale data visible while revalidating (better UX)
|
||||
revalidateOnFocus: false,
|
||||
// * Revalidate when reconnecting (fresh data after offline)
|
||||
revalidateOnReconnect: true,
|
||||
// * Retry failed requests
|
||||
shouldRetryOnError: true,
|
||||
errorRetryCount: 3,
|
||||
// * Error retry interval with exponential backoff
|
||||
errorRetryInterval: 5000,
|
||||
}
|
||||
|
||||
// * Hook for site data (loads once, refreshes rarely)
|
||||
export function useSite(siteId: string) {
|
||||
return useSWR<Site>(
|
||||
siteId ? ['site', siteId] : null,
|
||||
() => fetchers.site(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Site data changes rarely, refresh every 5 minutes
|
||||
refreshInterval: 5 * 60 * 1000,
|
||||
// * Deduping interval to prevent duplicate requests
|
||||
dedupingInterval: 30 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for dashboard summary data (refreshed less frequently)
|
||||
export function useDashboard(siteId: string, start: string, end: string) {
|
||||
return useSWR(
|
||||
siteId && start && end ? ['dashboard', siteId, start, end] : null,
|
||||
() => fetchers.dashboard(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for dashboard summary
|
||||
refreshInterval: 60 * 1000,
|
||||
// * Deduping interval to prevent duplicate requests
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for stats (refreshed less frequently)
|
||||
export function useStats(siteId: string, start: string, end: string) {
|
||||
return useSWR<Stats>(
|
||||
siteId && start && end ? ['stats', siteId, start, end] : null,
|
||||
() => fetchers.stats(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for stats
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for daily stats (refreshed less frequently)
|
||||
export function useDailyStats(
|
||||
siteId: string,
|
||||
start: string,
|
||||
end: string,
|
||||
interval: 'hour' | 'day' | 'minute'
|
||||
) {
|
||||
return useSWR<DailyStat[]>(
|
||||
siteId && start && end ? ['dailyStats', siteId, start, end, interval] : null,
|
||||
() => fetchers.dailyStats(siteId, start, end, interval),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh every 60 seconds for chart data
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for realtime visitor count (refreshed frequently)
|
||||
export function useRealtime(siteId: string, refreshInterval: number = 5000) {
|
||||
return useSWR<{ visitors: number }>(
|
||||
siteId ? ['realtime', siteId] : null,
|
||||
() => fetchers.realtime(siteId),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
// * Refresh frequently for real-time data (default 5 seconds)
|
||||
refreshInterval,
|
||||
// * Short deduping for real-time
|
||||
dedupingInterval: 2000,
|
||||
// * Keep previous data while loading new data
|
||||
keepPreviousData: true,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard overview data (Fix 4.2: Efficient Data Transfer)
|
||||
export function useDashboardOverview(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardOverviewData>(
|
||||
siteId && start && end ? ['dashboardOverview', siteId, start, end] : null,
|
||||
() => fetchers.dashboardOverview(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard pages data
|
||||
export function useDashboardPages(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardPagesData>(
|
||||
siteId && start && end ? ['dashboardPages', siteId, start, end] : null,
|
||||
() => fetchers.dashboardPages(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard locations data
|
||||
export function useDashboardLocations(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardLocationsData>(
|
||||
siteId && start && end ? ['dashboardLocations', siteId, start, end] : null,
|
||||
() => fetchers.dashboardLocations(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard devices data
|
||||
export function useDashboardDevices(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardDevicesData>(
|
||||
siteId && start && end ? ['dashboardDevices', siteId, start, end] : null,
|
||||
() => fetchers.dashboardDevices(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard referrers data
|
||||
export function useDashboardReferrers(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardReferrersData>(
|
||||
siteId && start && end ? ['dashboardReferrers', siteId, start, end] : null,
|
||||
() => fetchers.dashboardReferrers(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard performance data
|
||||
export function useDashboardPerformance(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardPerformanceData>(
|
||||
siteId && start && end ? ['dashboardPerformance', siteId, start, end] : null,
|
||||
() => fetchers.dashboardPerformance(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Hook for focused dashboard goals data
|
||||
export function useDashboardGoals(siteId: string, start: string, end: string) {
|
||||
return useSWR<DashboardGoalsData>(
|
||||
siteId && start && end ? ['dashboardGoals', siteId, start, end] : null,
|
||||
() => fetchers.dashboardGoals(siteId, start, end),
|
||||
{
|
||||
...dashboardSWRConfig,
|
||||
refreshInterval: 60 * 1000,
|
||||
dedupingInterval: 10 * 1000,
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// * Re-export for convenience
|
||||
export { fetchers }
|
||||
Reference in New Issue
Block a user