Redesign visitor log as readable cards replacing dense table

Each visit is now a card showing name/IP, page path, location, device,
visit count, new/returning tag, and timestamp at a glance. Logged-in users
show their display name prominently. Click to expand still shows full page
history and raw details. Adds a purple "Logged in" tag for study users.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-06-17 14:24:16 -04:00
parent 31426fccba
commit 945a2e160e
2 changed files with 185 additions and 69 deletions
+61 -69
View File
@@ -450,76 +450,68 @@ export function AnalyticsPanel({
{stats.visitors.recentVisits.length === 0 ? (
<p className="admin-stats-note">No visitor records yet.</p>
) : (
<div className="admin-visits-table-scroll">
<table className="admin-visits-table">
<thead>
<tr>
<th>Time</th>
<th>IP</th>
<th>User</th>
<th>Country</th>
<th>Path</th>
<th>Referrer</th>
<th>Device</th>
<th>Returning</th>
<th>Visit #</th>
</tr>
</thead>
<tbody>
{stats.visitors.recentVisits.map(row => {
const rowKey = `${row.visitorId}-${row.at}`
const isExpanded = expandedVisitor === rowKey
return (
<>
<tr
key={rowKey}
className={`admin-visitor-row${isExpanded ? ' admin-visitor-row--expanded' : ''}`}
style={{ cursor: 'pointer' }}
onClick={() => setExpandedVisitor(isExpanded ? null : rowKey)}
title="Click to view page history"
>
<td>{formatDate(row.at)}</td>
<td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></td>
<td>{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : ''}</td>
<td>{row.country || '—'}</td>
<td className="admin-visitor-path" title={row.path}>{row.path}</td>
<td className="admin-visitor-path" title={row.referrer || ''}>{row.referrer || '—'}</td>
<td>{row.device || '—'}</td>
<td>
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
{row.returningVisitor ? 'Returning' : 'New'}
</span>
</td>
<td className="admin-visitor-count">{row.visitCount} {isExpanded ? '▲' : '▼'}</td>
</tr>
{isExpanded && (row.pageHistory ?? []).length > 0 && (
<tr key={`${rowKey}-history`} className="admin-visitor-history-row">
<td colSpan={9}>
<div className="admin-visitor-history">
<p className="admin-visitor-history-label">Full page history for this visitor ({(row.pageHistory ?? []).length} pages):</p>
<ol className="admin-visitor-history-list">
{[...(row.pageHistory ?? [])].reverse().map((entry, i) => (
<li key={i} className="admin-visitor-history-entry">
<span className="admin-visitor-history-time">{formatDate(entry.at)}</span>
<span className="admin-visitor-history-path">{entry.path}</span>
{entry.referrer && <span className="admin-visitor-history-ref">from {entry.referrer}</span>}
</li>
))}
</ol>
</div>
</td>
</tr>
<div className="admin-visitor-cards">
{stats.visitors.recentVisits.map(row => {
const rowKey = `${row.visitorId}-${row.at}`
const isExpanded = expandedVisitor === rowKey
const history = row.pageHistory ?? []
const location = [row.city !== 'Unknown' && row.city, row.state !== 'Unknown' && row.state, row.country !== 'Unknown' && row.country].filter(Boolean).join(', ') || 'Unknown location'
return (
<div key={rowKey} className={`admin-visitor-card${isExpanded ? ' admin-visitor-card--expanded' : ''}`}>
<button
className="admin-visitor-card__main"
onClick={() => setExpandedVisitor(isExpanded ? null : rowKey)}
aria-expanded={isExpanded}
>
<div className="admin-visitor-card__left">
<span className="admin-visitor-card__name">
{row.studyUser ? (row.studyUser.displayName || row.studyUser.username) : <code className="admin-visitor-ip">{maskIp(row.ip)}</code>}
</span>
<span className="admin-visitor-card__path">{row.path}</span>
</div>
<div className="admin-visitor-card__right">
<span className="admin-visitor-card__meta">{location}</span>
<span className="admin-visitor-card__meta">{row.device || 'unknown'} · visit #{row.visitCount}</span>
<div className="admin-visitor-card__tags">
<span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
{row.returningVisitor ? 'Returning' : 'New'}
</span>
{row.studyUser && <span className="admin-visitor-tag admin-visitor-tag--user">Logged in</span>}
<span className="admin-visitor-card__time">{formatDate(row.at)}</span>
</div>
</div>
<span className="admin-visitor-card__chevron">{isExpanded ? '▲' : ''}</span>
</button>
{isExpanded && (
<div className="admin-visitor-card__detail">
<div className="admin-visitor-card__detail-meta">
<span><strong>IP</strong> {maskIp(row.ip)}</span>
{row.studyUser && <span><strong>Username</strong> {row.studyUser.username}</span>}
{row.referrer && <span><strong>Referrer</strong> {row.referrer}</span>}
<span><strong>Location</strong> {[row.city, row.county, row.state, row.country].filter(v => v && v !== 'Unknown').join(', ') || '—'}</span>
</div>
{history.length > 0 ? (
<>
<p className="admin-visitor-history-label">Page history ({history.length} pages)</p>
<ol className="admin-visitor-history-list">
{[...history].reverse().map((entry, i) => (
<li key={i} className="admin-visitor-history-entry">
<span className="admin-visitor-history-time">{formatDate(entry.at)}</span>
<span className="admin-visitor-history-path">{entry.path}</span>
{entry.referrer && <span className="admin-visitor-history-ref">from {entry.referrer}</span>}
</li>
))}
</ol>
</>
) : (
<p style={{ color: '#8a7f5a', fontSize: '0.82rem', margin: 0 }}>No page history yet recorded after consent is given.</p>
)}
{isExpanded && (row.pageHistory ?? []).length === 0 && (
<tr key={`${rowKey}-history-empty`} className="admin-visitor-history-row">
<td colSpan={8}><p style={{ padding: '0.5rem 1rem', color: '#8a7f5a', margin: 0, fontSize: '0.85rem' }}>No page history yet history is built from SPA navigations after consent.</p></td>
</tr>
)}
</>
)
})}
</tbody>
</table>
</div>
)}
</div>
)
})}
</div>
)}
</div>