Merge pull request #22 from ciphera-net/staging
[PULSE-52] Top Referrers favicons, display names, and merge by name
This commit is contained in:
11
CHANGELOG.md
11
CHANGELOG.md
@@ -4,6 +4,14 @@ 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.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.
|
||||
|
||||
## [0.2.0-alpha] - 2026-02-11
|
||||
|
||||
### Added
|
||||
@@ -21,6 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
|
||||
---
|
||||
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.2.0-alpha...HEAD
|
||||
[Unreleased]: https://github.com/ciphera-net/pulse/compare/v0.3.0-alpha...HEAD
|
||||
[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
|
||||
[0.1.0-alpha]: https://github.com/ciphera-net/pulse/releases/tag/v0.1.0-alpha
|
||||
|
||||
@@ -7,6 +7,7 @@ import jsPDF from 'jspdf'
|
||||
import autoTable from 'jspdf-autotable'
|
||||
import type { DailyStat } from './Chart'
|
||||
import { formatNumber, formatDuration } from '@/lib/utils/format'
|
||||
import { getReferrerDisplayName, mergeReferrersByDisplayName } from '@/lib/utils/icons'
|
||||
import type { TopPage, TopReferrer } from '@/lib/api/stats'
|
||||
|
||||
interface ExportModalProps {
|
||||
@@ -278,7 +279,8 @@ export default function ExportModal({ isOpen, onClose, data, stats, topPages, to
|
||||
doc.text('Top Referrers', 14, finalY)
|
||||
finalY += 5
|
||||
|
||||
const referrersData = topReferrers.slice(0, 10).map(r => [r.referrer, formatNumber(r.pageviews)])
|
||||
const mergedReferrers = mergeReferrersByDisplayName(topReferrers)
|
||||
const referrersData = mergedReferrers.slice(0, 10).map(r => [getReferrerDisplayName(r.referrer), formatNumber(r.pageviews)])
|
||||
|
||||
autoTable(doc, {
|
||||
startY: finalY,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { formatNumber } from '@/lib/utils/format'
|
||||
import { getReferrerIcon } from '@/lib/utils/icons'
|
||||
import { getReferrerDisplayName, getReferrerFavicon, getReferrerIcon, mergeReferrersByDisplayName } from '@/lib/utils/icons'
|
||||
import { Modal, GlobeIcon } from '@ciphera-net/ui'
|
||||
import { getTopReferrers, TopReferrer } from '@/lib/api/stats'
|
||||
|
||||
@@ -19,16 +19,35 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [fullData, setFullData] = useState<TopReferrer[]>([])
|
||||
const [isLoadingFull, setIsLoadingFull] = useState(false)
|
||||
const [faviconFailed, setFaviconFailed] = useState<Set<string>>(new Set())
|
||||
|
||||
// Filter out empty/unknown referrers
|
||||
const filteredReferrers = (referrers || []).filter(
|
||||
ref => ref.referrer && ref.referrer !== 'Unknown' && ref.referrer !== ''
|
||||
)
|
||||
|
||||
const hasData = filteredReferrers.length > 0
|
||||
const displayedReferrers = hasData ? filteredReferrers.slice(0, LIMIT) : []
|
||||
const mergedReferrers = mergeReferrersByDisplayName(filteredReferrers)
|
||||
|
||||
const hasData = mergedReferrers.length > 0
|
||||
const displayedReferrers = hasData ? mergedReferrers.slice(0, LIMIT) : []
|
||||
const emptySlots = Math.max(0, LIMIT - displayedReferrers.length)
|
||||
const showViewAll = hasData && filteredReferrers.length > LIMIT
|
||||
const showViewAll = hasData && mergedReferrers.length > LIMIT
|
||||
|
||||
function renderReferrerIcon(referrer: string) {
|
||||
const faviconUrl = getReferrerFavicon(referrer)
|
||||
const useFavicon = faviconUrl && !faviconFailed.has(referrer)
|
||||
if (useFavicon) {
|
||||
return (
|
||||
<img
|
||||
src={faviconUrl}
|
||||
alt=""
|
||||
className="w-5 h-5 flex-shrink-0 rounded object-contain"
|
||||
onError={() => setFaviconFailed((prev) => new Set(prev).add(referrer))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return <span className="text-lg flex-shrink-0">{getReferrerIcon(referrer)}</span>
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
@@ -80,8 +99,8 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
{displayedReferrers.map((ref, index) => (
|
||||
<div key={index} className="flex items-center justify-between h-9 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
<span className="text-lg flex-shrink-0">{getReferrerIcon(ref.referrer)}</span>
|
||||
<span className="truncate" title={ref.referrer}>{ref.referrer}</span>
|
||||
{renderReferrerIcon(ref.referrer)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(ref.pageviews)}
|
||||
@@ -120,11 +139,11 @@ export default function TopReferrers({ referrers, collectReferrers = true, siteI
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">Loading...</p>
|
||||
</div>
|
||||
) : (
|
||||
(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (
|
||||
mergeReferrersByDisplayName(fullData.length > 0 ? fullData : filteredReferrers).map((ref, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-2 group hover:bg-neutral-50 dark:hover:bg-neutral-800 rounded-lg px-2 -mx-2 transition-colors">
|
||||
<div className="flex-1 truncate text-neutral-900 dark:text-white flex items-center gap-3">
|
||||
<span className="text-lg flex-shrink-0">{getReferrerIcon(ref.referrer)}</span>
|
||||
<span className="truncate" title={ref.referrer}>{ref.referrer}</span>
|
||||
{renderReferrerIcon(ref.referrer)}
|
||||
<span className="truncate" title={ref.referrer}>{getReferrerDisplayName(ref.referrer)}</span>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-neutral-600 dark:text-neutral-400 ml-4">
|
||||
{formatNumber(ref.pageviews)}
|
||||
|
||||
@@ -78,11 +78,123 @@ export function getReferrerIcon(referrerName: string) {
|
||||
return <FaGlobe className="text-neutral-400" />
|
||||
}
|
||||
|
||||
export function getReferrerFavicon(referrer: string) {
|
||||
try {
|
||||
const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`);
|
||||
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
const REFERRER_NO_FAVICON = ['direct', 'unknown', '']
|
||||
|
||||
/** Common subdomains to skip when deriving the main label (e.g. l.instagram.com → instagram). */
|
||||
const REFERRER_SUBDOMAIN_SKIP = new Set([
|
||||
'www', 'm', 'l', 'app', 'mobile', 'search', 'mail', 'drive', 'maps', 'docs',
|
||||
'sub', 'api', 'static', 'cdn', 'blog', 'shop', 'support', 'help', 'link',
|
||||
])
|
||||
|
||||
/**
|
||||
* Override map for display names when the heuristic would be wrong (casing or brand alias).
|
||||
* Keys: lowercase label or hostname. Values: exact display name.
|
||||
*/
|
||||
const REFERRER_DISPLAY_OVERRIDES: Record<string, string> = {
|
||||
chatgpt: 'ChatGPT',
|
||||
linkedin: 'LinkedIn',
|
||||
youtube: 'YouTube',
|
||||
reddit: 'Reddit',
|
||||
github: 'GitHub',
|
||||
duckduckgo: 'DuckDuckGo',
|
||||
whatsapp: 'WhatsApp',
|
||||
telegram: 'Telegram',
|
||||
pinterest: 'Pinterest',
|
||||
snapchat: 'Snapchat',
|
||||
tumblr: 'Tumblr',
|
||||
quora: 'Quora',
|
||||
't.co': 'X',
|
||||
'x.com': 'X',
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the hostname for a referrer string (URL or plain hostname), or null if invalid.
|
||||
*/
|
||||
function getReferrerHostname(referrer: string): string | null {
|
||||
if (!referrer || typeof referrer !== 'string') return null
|
||||
const trimmed = referrer.trim()
|
||||
if (REFERRER_NO_FAVICON.includes(trimmed.toLowerCase())) return null
|
||||
try {
|
||||
const url = new URL(trimmed.startsWith('http') ? trimmed : `https://${trimmed}`)
|
||||
return url.hostname.toLowerCase()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derives the main label from a hostname (e.g. "l.instagram.com" → "instagram", "google.com" → "google").
|
||||
*/
|
||||
function getReferrerLabel(hostname: string): string {
|
||||
const withoutWww = hostname.startsWith('www.') ? hostname.slice(4) : hostname
|
||||
const parts = withoutWww.split('.')
|
||||
if (parts.length >= 2 && REFERRER_SUBDOMAIN_SKIP.has(parts[0])) {
|
||||
return parts[1]
|
||||
}
|
||||
return parts[0] ?? withoutWww
|
||||
}
|
||||
|
||||
function capitalizeLabel(label: string): string {
|
||||
if (!label) return label
|
||||
return label.charAt(0).toUpperCase() + label.slice(1).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a friendly display name for the referrer (e.g. "Google" instead of "google.com").
|
||||
* Uses a heuristic (hostname → main label → capitalize) plus a small override map for famous brands.
|
||||
*/
|
||||
export function getReferrerDisplayName(referrer: string): string {
|
||||
if (!referrer || typeof referrer !== 'string') return referrer || ''
|
||||
const trimmed = referrer.trim()
|
||||
if (trimmed === '') return ''
|
||||
const hostname = getReferrerHostname(trimmed)
|
||||
if (!hostname) return trimmed
|
||||
const overrideByHostname = REFERRER_DISPLAY_OVERRIDES[hostname]
|
||||
if (overrideByHostname) return overrideByHostname
|
||||
const label = getReferrerLabel(hostname)
|
||||
const overrideByLabel = REFERRER_DISPLAY_OVERRIDES[label]
|
||||
if (overrideByLabel) return overrideByLabel
|
||||
return capitalizeLabel(label)
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges referrer rows that share the same display name (e.g. chatgpt.com and https://chatgpt.com/...),
|
||||
* summing pageviews and keeping one referrer per group for icon/tooltip. Sorted by pageviews desc.
|
||||
*/
|
||||
export function mergeReferrersByDisplayName(
|
||||
items: Array<{ referrer: string; pageviews: number }>
|
||||
): Array<{ referrer: string; pageviews: number }> {
|
||||
const byDisplayName = new Map<string, { referrer: string; pageviews: number; maxSingle: number }>()
|
||||
for (const ref of items) {
|
||||
const name = getReferrerDisplayName(ref.referrer)
|
||||
const existing = byDisplayName.get(name)
|
||||
if (!existing) {
|
||||
byDisplayName.set(name, { referrer: ref.referrer, pageviews: ref.pageviews, maxSingle: ref.pageviews })
|
||||
} else {
|
||||
existing.pageviews += ref.pageviews
|
||||
if (ref.pageviews > existing.maxSingle) {
|
||||
existing.maxSingle = ref.pageviews
|
||||
existing.referrer = ref.referrer
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(byDisplayName.values())
|
||||
.map(({ referrer, pageviews }) => ({ referrer, pageviews }))
|
||||
.sort((a, b) => b.pageviews - a.pageviews)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a favicon URL for the referrer's domain, or null for non-URL referrers
|
||||
* (e.g. "Direct", "Unknown") so callers can show an icon fallback instead.
|
||||
*/
|
||||
export function getReferrerFavicon(referrer: string): string | null {
|
||||
if (!referrer || typeof referrer !== 'string') return null
|
||||
const normalized = referrer.trim().toLowerCase()
|
||||
if (REFERRER_NO_FAVICON.includes(normalized)) return null
|
||||
try {
|
||||
const url = new URL(referrer.startsWith('http') ? referrer : `https://${referrer}`)
|
||||
return `https://www.google.com/s2/favicons?domain=${url.hostname}&sz=32`
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-frontend",
|
||||
"version": "0.1.0-alpha",
|
||||
"version": "0.3.0-alpha",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
Reference in New Issue
Block a user