feat: make the Ghost Log actually do something
It was read-only: one link on the whole page ("back to séance"), no way
to get from "I talked to this spirit" to anything about them. Fixed with
three additions, all client-side against data already fetched in one
call — no backend change:
- Every entity glyph and name links straight to its Codex page.
- Echoes past the first are collapsed behind a "+N more" toggle instead
of always showing up to 3 — a card reads cleanly at a glance, with
detail one tap away.
- A channel filter (only shown once >1 mode is actually present in the
log) so a seeker with a long history can find "just the radio nights."
Deliberately did NOT add a "revisit this spirit" button. Contact is a
probabilistic channel draw (RETURN_CHANCE), not "resume this entity" —
a button that just navigated to /seance would be indistinguishable from
the existing back link and would imply a guarantee the app doesn't make.
Cosmetic interactivity isn't worth shipping.
6 new tests. 372 frontend tests pass total; i18n parity holds.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,11 @@
|
||||
"error": "the log will not open — the veil resists tonight",
|
||||
"empty": "no séances yet — the log waits",
|
||||
"noEntity": "no one answered",
|
||||
"filterLabel": "filter by channel",
|
||||
"filterAll": "all channels",
|
||||
"showMore_one": "+{{count}} more echo",
|
||||
"showMore_other": "+{{count}} more echoes",
|
||||
"showLess": "show less",
|
||||
"counts": {
|
||||
"question_one": "{{count}} question",
|
||||
"question_other": "{{count}} questions",
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
"error": "el registro no se abre — el velo se resiste esta noche",
|
||||
"empty": "aún no hay sesiones — el registro espera",
|
||||
"noEntity": "nadie respondió",
|
||||
"filterLabel": "filtrar por canal",
|
||||
"filterAll": "todos los canales",
|
||||
"showMore_one": "+{{count}} eco más",
|
||||
"showMore_other": "+{{count}} ecos más",
|
||||
"showLess": "mostrar menos",
|
||||
"counts": {
|
||||
"question_one": "{{count}} pregunta",
|
||||
"question_other": "{{count}} preguntas",
|
||||
|
||||
@@ -79,6 +79,16 @@
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
a.ghostlog-glyph {
|
||||
border-radius: 50%;
|
||||
transition: filter 140ms ease;
|
||||
}
|
||||
|
||||
a.ghostlog-glyph:hover,
|
||||
a.ghostlog-glyph:focus-visible {
|
||||
filter: drop-shadow(0 0 8px rgba(124, 255, 178, 0.6));
|
||||
}
|
||||
|
||||
.ghostlog-body {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
@@ -91,6 +101,26 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ghostlog-who-link {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
border-radius: 3px;
|
||||
transition: text-shadow 140ms ease;
|
||||
}
|
||||
|
||||
.ghostlog-who-link:hover .ghostlog-name,
|
||||
.ghostlog-who-link:focus-visible .ghostlog-name {
|
||||
text-shadow: 0 0 10px rgba(124, 255, 178, 0.6);
|
||||
}
|
||||
|
||||
.ghostlog-who-link:focus-visible {
|
||||
outline: 1px solid #7cffb2;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.ghostlog-name {
|
||||
font-family: Cinzel, serif;
|
||||
font-size: 1rem;
|
||||
@@ -130,6 +160,56 @@
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.ghostlog-expand {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #b26bff;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.3rem 0 0.1rem;
|
||||
cursor: pointer;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.ghostlog-expand:hover,
|
||||
.ghostlog-expand:focus-visible {
|
||||
text-shadow: 0 0 8px rgba(178, 107, 255, 0.6);
|
||||
}
|
||||
|
||||
.ghostlog-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
margin: 0 0 1.1rem;
|
||||
}
|
||||
|
||||
.ghostlog-filter {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(124, 255, 178, 0.25);
|
||||
border-radius: 3px;
|
||||
color: rgba(211, 233, 219, 0.6);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.4rem 0.7rem;
|
||||
min-height: 36px;
|
||||
cursor: pointer;
|
||||
transition: color 140ms ease, border-color 140ms ease, background 140ms ease;
|
||||
}
|
||||
|
||||
.ghostlog-filter:hover {
|
||||
color: #7cffb2;
|
||||
}
|
||||
|
||||
.ghostlog-filter.active {
|
||||
color: #7cffb2;
|
||||
border-color: rgba(124, 255, 178, 0.6);
|
||||
background: rgba(124, 255, 178, 0.08);
|
||||
}
|
||||
|
||||
/* phone widths */
|
||||
@media (max-width: 420px) {
|
||||
.ghostlog-page {
|
||||
|
||||
126
frontend/src/pages/GhostLogPage.test.tsx
Normal file
126
frontend/src/pages/GhostLogPage.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import { render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import '../i18n'
|
||||
import { AuthProvider } from '../state/auth'
|
||||
import * as api from '../api'
|
||||
import { GhostLogPage } from './GhostLogPage'
|
||||
|
||||
vi.mock('../api')
|
||||
|
||||
function mockFetch(handler: (input: RequestInfo | URL) => Response | Promise<Response>) {
|
||||
vi.stubGlobal('fetch', vi.fn(handler))
|
||||
}
|
||||
|
||||
function seance(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 's-1',
|
||||
started_at: new Date(Date.now() - 5 * 60_000).toISOString(),
|
||||
ended_at: null,
|
||||
mode: 'evp',
|
||||
entity: {
|
||||
id: 'e-1',
|
||||
name: 'Mirela',
|
||||
epithet: 'the Drowned',
|
||||
rarity: 'rare',
|
||||
visual: { hue: 210, form: 'wisp' },
|
||||
},
|
||||
counts: { question: 3, anomaly: 2, utterance: 5 },
|
||||
echoes: ['the water remembers', 'do not follow the light', 'I was here first'],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(api.me).mockResolvedValue({ id: 'u-1', username: 'seeker', unlocks: [], essence: 0 })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
async function renderPage() {
|
||||
render(
|
||||
<AuthProvider>
|
||||
<MemoryRouter initialEntries={['/log']}>
|
||||
<GhostLogPage />
|
||||
</MemoryRouter>
|
||||
</AuthProvider>,
|
||||
)
|
||||
await waitFor(() => expect(api.me).toHaveBeenCalled())
|
||||
}
|
||||
|
||||
describe('GhostLogPage', () => {
|
||||
it('links a session with an entity straight to its Codex page', async () => {
|
||||
mockFetch(async () => Response.json({ seances: [seance()] }))
|
||||
await renderPage()
|
||||
|
||||
const link = await screen.findByRole('link', { name: /Mirela/ })
|
||||
expect(link).toHaveAttribute('href', '/codex/e-1')
|
||||
})
|
||||
|
||||
it('shows only the first echo and reveals the rest on demand', async () => {
|
||||
mockFetch(async () => Response.json({ seances: [seance()] }))
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('the water remembers')
|
||||
expect(screen.queryByText('do not follow the light')).toBeNull()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /more echo/i }))
|
||||
expect(screen.getByText('do not follow the light')).toBeInTheDocument()
|
||||
expect(screen.getByText('I was here first')).toBeInTheDocument()
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /show less/i }))
|
||||
expect(screen.queryByText('do not follow the light')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not show an expand control when there is only one echo', async () => {
|
||||
mockFetch(async () =>
|
||||
Response.json({ seances: [seance({ echoes: ['only one line'] })] }),
|
||||
)
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText('only one line')
|
||||
expect(screen.queryByRole('button', { name: /more echo/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('filters the log by channel when more than one mode is present', async () => {
|
||||
mockFetch(async () =>
|
||||
Response.json({
|
||||
seances: [
|
||||
seance({ id: 's-evp', mode: 'evp', entity: { ...seance().entity, name: 'Mirela' } }),
|
||||
seance({ id: 's-radio', mode: 'radio', entity: { ...seance().entity, id: 'e-2', name: 'Ash' } }),
|
||||
],
|
||||
}),
|
||||
)
|
||||
await renderPage()
|
||||
|
||||
await screen.findByRole('link', { name: /Mirela/ })
|
||||
expect(screen.getByRole('link', { name: /Ash/ })).toBeInTheDocument()
|
||||
|
||||
const filters = screen.getByRole('group', { name: /filter/i })
|
||||
await userEvent.click(within(filters).getByRole('button', { name: /radio/i }))
|
||||
|
||||
expect(screen.queryByRole('link', { name: /Mirela/ })).toBeNull()
|
||||
expect(screen.getByRole('link', { name: /Ash/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the filter row entirely when every session shares one channel', async () => {
|
||||
mockFetch(async () =>
|
||||
Response.json({ seances: [seance({ mode: 'evp' }), seance({ id: 's-2', mode: 'evp' })] }),
|
||||
)
|
||||
await renderPage()
|
||||
|
||||
await screen.findAllByText('Mirela')
|
||||
expect(screen.queryByRole('group', { name: /filter/i })).toBeNull()
|
||||
})
|
||||
|
||||
it('renders a session with no entity without a dangling link', async () => {
|
||||
mockFetch(async () => Response.json({ seances: [seance({ entity: null })] }))
|
||||
await renderPage()
|
||||
|
||||
await screen.findByText(/no one answered/i)
|
||||
expect(screen.queryByRole('link', { name: /codex/i })).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
// The Ghost Log — a seeker's own recent séances, recalled from
|
||||
// GET /api/seances/recent. Usability wave Workstream C (#7).
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { TFunction } from 'i18next'
|
||||
@@ -32,6 +32,7 @@ type LoadState = 'loading' | 'error' | 'ready'
|
||||
|
||||
const COUNT_KINDS = ['question', 'anomaly', 'utterance'] as const
|
||||
const GHOST_FORMS: readonly string[] = ['wisp', 'banshee', 'fairy', 'shade']
|
||||
const ALL_MODES = 'all'
|
||||
|
||||
function relativeTime(iso: string, t: TFunction): string {
|
||||
const then = new Date(iso).getTime()
|
||||
@@ -49,6 +50,8 @@ export function GhostLogPage() {
|
||||
const { user, checking } = useAuth()
|
||||
const [seances, setSeances] = useState<LogSeance[]>([])
|
||||
const [state, setState] = useState<LoadState>('loading')
|
||||
const [modeFilter, setModeFilter] = useState<string>(ALL_MODES)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
@@ -68,6 +71,17 @@ export function GhostLogPage() {
|
||||
return () => controller.abort()
|
||||
}, [user])
|
||||
|
||||
// Client-side only — the whole log is already fetched in one call, and
|
||||
// a handful of sessions never justifies a round trip just to filter them.
|
||||
const modesPresent = useMemo(
|
||||
() => Array.from(new Set(seances.map((s) => s.mode))).sort(),
|
||||
[seances],
|
||||
)
|
||||
const visible = useMemo(
|
||||
() => (modeFilter === ALL_MODES ? seances : seances.filter((s) => s.mode === modeFilter)),
|
||||
[seances, modeFilter],
|
||||
)
|
||||
|
||||
if (checking) return null
|
||||
if (!user) return <Navigate to="/enter" replace />
|
||||
|
||||
@@ -91,9 +105,31 @@ export function GhostLogPage() {
|
||||
<p className="ghostlog-note ghostlog-empty">{t('log.empty')}</p>
|
||||
)}
|
||||
|
||||
{state === 'ready' && seances.length > 0 && modesPresent.length > 1 && (
|
||||
<div className="ghostlog-filters" role="group" aria-label={t('log.filterLabel')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`ghostlog-filter ${modeFilter === ALL_MODES ? 'active' : ''}`}
|
||||
onClick={() => setModeFilter(ALL_MODES)}
|
||||
>
|
||||
{t('log.filterAll')}
|
||||
</button>
|
||||
{modesPresent.map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
className={`ghostlog-filter ${modeFilter === mode ? 'active' : ''}`}
|
||||
onClick={() => setModeFilter(mode)}
|
||||
>
|
||||
{t(`seance.modes.${mode}`, mode)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state === 'ready' && seances.length > 0 && (
|
||||
<ul className="ghostlog-list">
|
||||
{seances.map((seance) => {
|
||||
{visible.map((seance) => {
|
||||
const visual = seance.entity?.visual ?? {}
|
||||
const form: GhostForm = GHOST_FORMS.includes(visual.form ?? '')
|
||||
? (visual.form as GhostForm)
|
||||
@@ -101,20 +137,31 @@ export function GhostLogPage() {
|
||||
const countLine = COUNT_KINDS.filter((kind) => (seance.counts[kind] ?? 0) > 0)
|
||||
.map((kind) => t(`log.counts.${kind}`, { count: seance.counts[kind] }))
|
||||
.join(' · ')
|
||||
const expanded = expandedId === seance.id
|
||||
const hasMoreToShow = seance.echoes.length > 1
|
||||
return (
|
||||
<li key={seance.id} className="ghostlog-card">
|
||||
<div className="ghostlog-glyph">
|
||||
<GhostGlyph hue={visual.hue ?? 150} form={form} size={44} />
|
||||
</div>
|
||||
{seance.entity ? (
|
||||
// The whole point of a log is that it leads somewhere —
|
||||
// straight through to everything the Codex knows about
|
||||
// whoever answered.
|
||||
<Link to={`/codex/${seance.entity.id}`} className="ghostlog-glyph">
|
||||
<GhostGlyph hue={visual.hue ?? 150} form={form} size={44} />
|
||||
</Link>
|
||||
) : (
|
||||
<div className="ghostlog-glyph">
|
||||
<GhostGlyph hue={visual.hue ?? 150} form={form} size={44} />
|
||||
</div>
|
||||
)}
|
||||
<div className="ghostlog-body">
|
||||
<div className="ghostlog-who">
|
||||
{seance.entity ? (
|
||||
<>
|
||||
<Link to={`/codex/${seance.entity.id}`} className="ghostlog-who-link">
|
||||
<span className="ghostlog-name">{seance.entity.name}</span>
|
||||
{seance.entity.epithet && (
|
||||
<span className="ghostlog-epithet">{seance.entity.epithet}</span>
|
||||
)}
|
||||
</>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="ghostlog-name ghostlog-noone">
|
||||
{t('log.noEntity')}
|
||||
@@ -134,12 +181,24 @@ export function GhostLogPage() {
|
||||
</div>
|
||||
{seance.echoes.length > 0 && (
|
||||
<div className="ghostlog-echoes">
|
||||
{seance.echoes.map((echo, i) => (
|
||||
{(expanded ? seance.echoes : seance.echoes.slice(0, 1)).map((echo, i) => (
|
||||
<p key={i} className="ghostlog-echo">
|
||||
<span aria-hidden>» </span>
|
||||
{echo}
|
||||
</p>
|
||||
))}
|
||||
{hasMoreToShow && (
|
||||
<button
|
||||
type="button"
|
||||
className="ghostlog-expand"
|
||||
onClick={() => setExpandedId(expanded ? null : seance.id)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded
|
||||
? t('log.showLess')
|
||||
: t('log.showMore', { count: seance.echoes.length - 1 })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user