feat: dummy journey timeline

This commit is contained in:
kemal committed 2026-07-13 16:41:18 +01:00
1 parent 7f57919318
commit 39de6061e2
2 files changed
+275 -2

No files matched your search

@@ -0,0 +1,263 @@
/**
* UserJourney — PROTOTYPE
*
* This is a demo-quality prototype of the "User Journey" tab. It renders a
* chronological timeline reconstructed from Auth and PostgREST logs using
* HARDCODED MOCK DATA — there are no real log queries wired up yet.
*
* See PRFAQ: https://www.notion.so/supabase/User-Journey-PRFAQ (placeholder link)
*/
import { useParams } from 'common'
import { AlertTriangle, Database, ExternalLink, LogIn, RefreshCw, UserPlus } from 'lucide-react'
import Link from 'next/link'
import { useState } from 'react'
import { Button, cn, Separator } from 'ui'
import { Admonition } from 'ui-patterns/admonition'
import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { UserHeader } from './UserHeader'
import { PANEL_PADDING } from './Users.constants'
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
import { User } from '@/data/auth/users-infinite-query'
type JourneyEventStatus = 'success' | 'neutral' | 'error'
type JourneyEventSource = 'auth' | 'postgrest'
interface JourneyEvent {
id: string
timestamp: string // ISO, ms precision
source: JourneyEventSource
status: JourneyEventStatus
title: string
description: string
request?: { method: string; path: string; statusCode: number }
error?: { message: string; policy?: string; table?: string }
}
const MOCK_JOURNEY_EVENTS: JourneyEvent[] = [
{
id: '1',
timestamp: '2026-07-13T09:41:02.118Z',
source: 'auth',
status: 'success',
title: 'Signed up',
description: 'Auth · new user created via email signup',
},
{
id: '2',
timestamp: '2026-07-13T09:41:02.421Z',
source: 'auth',
status: 'success',
title: 'Authenticated',
description: 'Auth · session issued, JWT minted',
},
{
id: '3',
timestamp: '2026-07-13T09:41:03.905Z',
source: 'postgrest',
status: 'neutral',
title: 'Read profile',
description: 'PostgREST · fetched the current user profile',
request: { method: 'GET', path: '/rest/v1/profiles', statusCode: 200 },
},
{
id: '4',
timestamp: '2026-07-13T09:41:05.332Z',
source: 'postgrest',
status: 'neutral',
title: 'Created order',
description: 'PostgREST · inserted a new order row',
request: { method: 'POST', path: '/rest/v1/orders', statusCode: 201 },
},
{
id: '5',
timestamp: '2026-07-13T09:41:06.744Z',
source: 'postgrest',
status: 'error',
title: 'Write blocked',
description: 'PostgREST · insert rejected before reaching the table',
request: { method: 'POST', path: '/rest/v1/payments', statusCode: 403 },
error: {
message: 'Denied by RLS policy',
policy: 'payments_insert_owner',
table: 'payments',
},
},
]
const getEventIcon = (event: JourneyEvent) => {
if (event.status === 'error') return AlertTriangle
if (event.source === 'postgrest') return Database
return event.title === 'Signed up' ? UserPlus : LogIn
}
const RequestLine = ({ request }: { request: NonNullable<JourneyEvent['request']> }) => (
<span className="font-mono text-xs text-foreground-light">
{request.method} {request.path} · {request.statusCode}
</span>
)
interface UserJourneyProps {
user: User
}
export const UserJourney = ({ user }: UserJourneyProps) => {
const { ref } = useParams()
const [errorsOnly, setErrorsOnly] = useState(false)
const events = errorsOnly
? MOCK_JOURNEY_EVENTS.filter((event) => event.status === 'error')
: MOCK_JOURNEY_EVENTS
return (
<div>
<UserHeader user={user} />
<Separator />
<div className={cn('flex flex-col gap-y-4', PANEL_PADDING)}>
<div className="flex items-start justify-between">
<div>
<p>User journey</p>
</div>
<span className="text-xs text-foreground-lighter whitespace-nowrap pt-0.5">
Last 24 hours
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center">
<Button
variant={errorsOnly ? 'default' : 'secondary'}
className="rounded-r-none border-r-0"
onClick={() => setErrorsOnly(false)}
>
Show all
</Button>
<div className="border-button border border-l-0 py-3" />
<Button
variant={errorsOnly ? 'secondary' : 'default'}
className="rounded-l-none border-l-0"
onClick={() => setErrorsOnly(true)}
>
Errors only
</Button>
</div>
<div className="flex items-center gap-x-2">
<ButtonTooltip
asChild
variant="default"
className="px-2"
tooltip={{ content: { text: 'Filter logs by user' } }}
>
<Link href={`/project/${ref}/logs/auth-logs?s=${user.id}`}>
<ExternalLink size={14} className="text-foreground-light" />
</Link>
</ButtonTooltip>
<ButtonTooltip
variant="default"
className="px-2"
icon={<RefreshCw />}
onClick={() => setErrorsOnly(false)}
tooltip={{ content: { text: 'Refresh' } }}
/>
</div>
</div>
{events.length === 0 ? (
<Admonition
type="note"
title="No journey available for this user"
description="Events from auth and API logs will be shown here"
/>
) : (
<ol className="flex flex-col">
{events.map((event, index) => {
const Icon = getEventIcon(event)
const isLast = index === events.length - 1
const isError = event.status === 'error'
return (
<li key={event.id} className="flex gap-x-4">
<div className="flex flex-col items-center">
<div
className={cn(
'flex items-center justify-center rounded-full w-6 h-6 shrink-0 border',
isError
? 'bg-destructive-200 border-destructive-400 text-destructive'
: 'bg-surface-100 border-strong text-foreground-light'
)}
>
<Icon size={12} strokeWidth={1.5} />
</div>
{!isLast && <div className="w-px grow bg-border my-1.5" />}
</div>
<div className={cn('flex-1 min-w-0', isLast ? 'pb-0' : 'pb-8')}>
{isError ? (
<div className="rounded-md border border-destructive-400 bg-destructive-200 overflow-hidden">
<div className="px-3 py-2.5">
<div className="flex items-center justify-between gap-x-2">
<p className="text-sm font-medium text-destructive">{event.title}</p>
<TimestampInfo
utcTimestamp={event.timestamp}
format="HH:mm:ss.SSS"
className="font-mono text-xs text-destructive whitespace-nowrap"
/>
</div>
<p className="text-xs text-foreground-light mt-0.5">
{event.description}
</p>
{event.request && (
<p className="mt-1.5">
<RequestLine request={event.request} />
</p>
)}
</div>
{event.error && (
<>
<Separator className="bg-destructive-400" />
<div className="px-3 py-2.5">
<p className="text-xs text-foreground-light">{event.error.message}</p>
<p className="text-xs mt-1">
<span className="text-foreground-light">policy </span>
<span className="font-mono text-foreground">
{event.error.policy}
</span>
<span className="text-foreground-light"> on table </span>
<span className="font-mono text-foreground">
{event.error.table}
</span>
</p>
</div>
</>
)}
</div>
) : (
<>
<div className="flex items-center justify-between gap-x-2">
<p className="text-sm font-medium text-foreground">{event.title}</p>
<TimestampInfo
utcTimestamp={event.timestamp}
format="HH:mm:ss.SSS"
className="font-mono text-xs text-foreground-lighter whitespace-nowrap"
/>
</div>
<p className="text-xs text-foreground-light mt-0.5">{event.description}</p>
{event.request && (
<p className="mt-1.5">
<RequestLine request={event.request} />
</p>
)}
</>
)}
</div>
</li>
)
})}
</ol>
)}
</div>
</div>
)
}
@@ -15,6 +15,7 @@ import {
import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader'
import { SimpleCodeBlock } from 'ui-patterns/SimpleCodeBlock'
import { UserJourney } from './UserJourney'
import { UserLogs } from './UserLogs'
import { UserOverview } from './UserOverview'
import { PANEL_PADDING } from './Users.constants'
@@ -30,7 +31,7 @@ export const UserPanel = () => {
parseAsString.withOptions({ history: 'push', clearOnDefault: true })
)
const [view, setView] = useState<'overview' | 'raw' | 'logs'>('overview')
const [view, setView] = useState<'overview' | 'raw' | 'logs' | 'journey'>('overview')
const [searchQuery, setSearchQuery] = useState('')
const { data: selectedUser, isPending } = useUserQuery({
@@ -67,7 +68,7 @@ export const UserPanel = () => {
<Tabs
value={view}
className="flex flex-col h-full"
onValueChange={(value) => setView(value as 'overview' | 'raw' | 'logs')}
onValueChange={(value) => setView(value as 'overview' | 'raw' | 'logs' | 'journey')}
>
{isPending ? (
<div>
@@ -91,6 +92,12 @@ export const UserPanel = () => {
>
Logs
</TabsTrigger>
<TabsTrigger
value="journey"
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent shadow-none!"
>
Journey
</TabsTrigger>
<TabsTrigger
value="raw"
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent shadow-none!"
@@ -107,6 +114,9 @@ export const UserPanel = () => {
<TabsContent value="logs" className={cn('mt-0 grow min-h-0 overflow-y-auto')}>
{selectedUser && <UserLogs user={selectedUser} />}
</TabsContent>
<TabsContent value="journey" className={cn('mt-0 grow min-h-0 overflow-y-auto')}>
{selectedUser && <UserJourney user={selectedUser} />}
</TabsContent>
<TabsContent
value="raw"
className={cn('mt-0 grow min-h-0 overflow-y-auto', PANEL_PADDING)}