From 1688907971d013b4db54f3bd72edee6eba321128 Mon Sep 17 00:00:00 2001 From: Indiana Date: Wed, 29 Jul 2026 19:16:49 +0000 Subject: [PATCH] feat: make the Ghost Log actually do something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/i18n/en.json | 5 + frontend/src/i18n/es.json | 5 + frontend/src/pages/GhostLogPage.css | 80 ++++++++++++++ frontend/src/pages/GhostLogPage.test.tsx | 126 +++++++++++++++++++++++ frontend/src/pages/GhostLogPage.tsx | 75 ++++++++++++-- 5 files changed, 283 insertions(+), 8 deletions(-) create mode 100644 frontend/src/pages/GhostLogPage.test.tsx diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 808461a..e71dcd6 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -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", diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 46c8fd2..799a266 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -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", diff --git a/frontend/src/pages/GhostLogPage.css b/frontend/src/pages/GhostLogPage.css index 403f34e..0885d79 100644 --- a/frontend/src/pages/GhostLogPage.css +++ b/frontend/src/pages/GhostLogPage.css @@ -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 { diff --git a/frontend/src/pages/GhostLogPage.test.tsx b/frontend/src/pages/GhostLogPage.test.tsx new file mode 100644 index 0000000..671c7e5 --- /dev/null +++ b/frontend/src/pages/GhostLogPage.test.tsx @@ -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) { + vi.stubGlobal('fetch', vi.fn(handler)) +} + +function seance(overrides: Record = {}) { + 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( + + + + + , + ) + 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() + }) +}) diff --git a/frontend/src/pages/GhostLogPage.tsx b/frontend/src/pages/GhostLogPage.tsx index 00e491b..6e5d577 100644 --- a/frontend/src/pages/GhostLogPage.tsx +++ b/frontend/src/pages/GhostLogPage.tsx @@ -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([]) const [state, setState] = useState('loading') + const [modeFilter, setModeFilter] = useState(ALL_MODES) + const [expandedId, setExpandedId] = useState(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 @@ -91,9 +105,31 @@ export function GhostLogPage() {

{t('log.empty')}

)} + {state === 'ready' && seances.length > 0 && modesPresent.length > 1 && ( +
+ + {modesPresent.map((mode) => ( + + ))} +
+ )} + {state === 'ready' && seances.length > 0 && (
    - {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 (
  • -
    - -
    + {seance.entity ? ( + // The whole point of a log is that it leads somewhere — + // straight through to everything the Codex knows about + // whoever answered. + + + + ) : ( +
    + +
    + )}
    {seance.entity ? ( - <> + {seance.entity.name} {seance.entity.epithet && ( {seance.entity.epithet} )} - + ) : ( {t('log.noEntity')} @@ -134,12 +181,24 @@ export function GhostLogPage() {
    {seance.echoes.length > 0 && (
    - {seance.echoes.map((echo, i) => ( + {(expanded ? seance.echoes : seance.echoes.slice(0, 1)).map((echo, i) => (

    » {echo}

    ))} + {hasMoreToShow && ( + + )}
    )}