2026-05-20 15:53:41 +02:00
|
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Recent-sessions card for the website-analytics page. Paginated list
|
|
|
|
|
|
* of visitor sessions (one row per unique session) with click-through to
|
|
|
|
|
|
* a detail sheet showing the full activity stream.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Umami's session model: one row per anonymous-device-fingerprint+IP+UA
|
|
|
|
|
|
* combination, with first/last visit timestamps + visit/view counts +
|
|
|
|
|
|
* geo + browser/os/device. The detail page shows the per-event stream
|
|
|
|
|
|
* (pageviews + custom events) within that session.
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import { useState } from 'react';
|
|
|
|
|
|
import { Globe, Smartphone, Monitor, Tablet, ChevronRight } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
|
|
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
|
import { CountryFlag } from '@/components/shared/country-flag';
|
2026-05-20 15:53:41 +02:00
|
|
|
|
import { getCountryName } from '@/lib/i18n/countries';
|
|
|
|
|
|
import { useUmamiSessions } from './use-website-analytics';
|
|
|
|
|
|
import { SessionDetailSheet } from './session-detail-sheet';
|
|
|
|
|
|
import { type DateRange } from '@/lib/analytics/range';
|
|
|
|
|
|
import type { UmamiSession } from '@/lib/services/umami.service';
|
|
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
|
range: DateRange;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function SessionsList({ range }: Props) {
|
|
|
|
|
|
const [page, setPage] = useState(1);
|
|
|
|
|
|
const [selected, setSelected] = useState<UmamiSession | null>(null);
|
|
|
|
|
|
const pageSize = 15;
|
|
|
|
|
|
const query = useUmamiSessions(range, { page, pageSize });
|
|
|
|
|
|
|
|
|
|
|
|
const sessions = query.data?.data?.data ?? [];
|
|
|
|
|
|
const total = query.data?.data?.count ?? 0;
|
|
|
|
|
|
const hasMore = page * pageSize < total;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle className="text-base">Recent sessions</CardTitle>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
{query.isLoading ? (
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
|
|
|
|
<Skeleton key={i} className="h-12 w-full" />
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : sessions.length === 0 ? (
|
|
|
|
|
|
<div className="py-6 text-center text-sm text-muted-foreground">
|
|
|
|
|
|
No sessions in this range.
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<ul className="divide-y divide-border">
|
|
|
|
|
|
{sessions.map((s, i) => (
|
|
|
|
|
|
// Umami's sessions endpoint can return rows with the
|
|
|
|
|
|
// same session id within a page when activity straddles
|
|
|
|
|
|
// a bucket boundary. Compose the key to dedupe.
|
|
|
|
|
|
<li key={`${s.id}-${i}`}>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
onClick={() => setSelected(s)}
|
|
|
|
|
|
className="group flex w-full items-center justify-between gap-3 py-3 text-left transition hover:bg-muted/40 -mx-2 px-2 rounded"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex min-w-0 flex-1 items-center gap-3">
|
|
|
|
|
|
<DeviceIcon device={s.device} />
|
|
|
|
|
|
<div className="min-w-0 flex-1">
|
chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:
- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
redirects (ocr to ai, reports to dashboard, invitations to users),
docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
let-reassign), set-state-in-effect disables in CountryFlag and
UploadForSigning preview-bytes effect, unused 'confirm' destructures in
interest contract + reservation tabs, unescaped apostrophe in test-template
card copy
2026-05-23 00:52:59 +02:00
|
|
|
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm">
|
|
|
|
|
|
{s.country ? (
|
|
|
|
|
|
<CountryFlag code={s.country} className="h-3 w-4" decorative />
|
|
|
|
|
|
) : null}
|
2026-05-20 15:53:41 +02:00
|
|
|
|
<span className="font-medium">
|
|
|
|
|
|
{getCountryName(s.country, 'en') || 'Unknown'}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
{s.city ? (
|
|
|
|
|
|
<span className="text-muted-foreground">{s.city}</span>
|
|
|
|
|
|
) : null}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
|
|
|
|
|
{s.browser} · {s.os} · {fmtTime(s.firstAt)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex shrink-0 items-center gap-3 text-xs text-muted-foreground">
|
|
|
|
|
|
<span className="tabular-nums">{s.views.toLocaleString()} views</span>
|
|
|
|
|
|
<ChevronRight
|
|
|
|
|
|
className="size-4 opacity-0 transition group-hover:opacity-100"
|
|
|
|
|
|
aria-hidden
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</li>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<div className="mt-4 flex items-center justify-between text-xs text-muted-foreground">
|
|
|
|
|
|
<span>
|
|
|
|
|
|
Showing {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of{' '}
|
|
|
|
|
|
{total.toLocaleString()}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
|
|
|
|
disabled={page === 1}
|
|
|
|
|
|
>
|
|
|
|
|
|
Previous
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
onClick={() => setPage((p) => p + 1)}
|
|
|
|
|
|
disabled={!hasMore}
|
|
|
|
|
|
>
|
|
|
|
|
|
Next
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
|
|
<SessionDetailSheet session={selected} range={range} onClose={() => setSelected(null)} />
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function DeviceIcon({ device }: { device: string }) {
|
|
|
|
|
|
const cls = 'size-5 shrink-0 text-muted-foreground';
|
|
|
|
|
|
switch (device.toLowerCase()) {
|
|
|
|
|
|
case 'mobile':
|
|
|
|
|
|
return <Smartphone className={cls} aria-hidden />;
|
|
|
|
|
|
case 'tablet':
|
|
|
|
|
|
return <Tablet className={cls} aria-hidden />;
|
|
|
|
|
|
case 'desktop':
|
|
|
|
|
|
case 'laptop':
|
|
|
|
|
|
return <Monitor className={cls} aria-hidden />;
|
|
|
|
|
|
default:
|
|
|
|
|
|
return <Globe className={cls} aria-hidden />;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function fmtTime(iso: string): string {
|
|
|
|
|
|
const d = new Date(iso);
|
|
|
|
|
|
if (isNaN(d.getTime())) return iso;
|
|
|
|
|
|
return d.toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
|
|
|
|
|
|
}
|