diff --git a/backend/app/main.py b/backend/app/main.py index c2c43c1..bcf72a7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ from app.routes.auth import router as auth_router from app.routes.codex import router as codex_router from app.routes.device import router as device_router from app.routes.inventory import router as inventory_router +from app.routes.seances import router as seances_router from app.routes.shop import router as shop_router from app.session_cleanup import delete_expired_sessions from app.ws import AUDIO_DIR @@ -97,6 +98,7 @@ app.include_router(auth_router) app.include_router(codex_router) app.include_router(device_router) app.include_router(inventory_router) +app.include_router(seances_router) app.include_router(shop_router) app.include_router(ws_router) diff --git a/backend/app/routes/seances.py b/backend/app/routes/seances.py new file mode 100644 index 0000000..1cb1acf --- /dev/null +++ b/backend/app/routes/seances.py @@ -0,0 +1,109 @@ +"""Ghost Log recap — a seeker's own recent séances, with who answered and +what was said. Workstream C of docs/superpowers/specs/ +2026-07-28-usability-wave-design.md (#7).""" + +from collections import defaultdict + +from fastapi import APIRouter, Depends +from sqlalchemy import desc, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db import get_db +from app.deps import get_current_user +from app.models.contact_session import ContactSession +from app.models.entity import Entity +from app.models.event import Event +from app.models.user import User + +router = APIRouter(prefix="/api", tags=["seances"]) + +RECENT_LIMIT = 12 +ECHOES_PER_SESSION = 3 + +# Spirit speech lands in the DB as kind="utterance" rows whose payload +# carries the flavor (see app.ws._speak) — these are the ones worth +# echoing back in a recap. +ECHO_PAYLOAD_KINDS = ("greeting", "reply", "manifest") + + +@router.get("/seances/recent") +async def recent_seances( + user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db) +): + # One query: the user's last sessions with their entity (may be null). + rows = ( + await db.execute( + select(ContactSession, Entity) + .outerjoin(Entity, ContactSession.entity_id == Entity.id) + .where(ContactSession.user_id == user.id) + .order_by(desc(ContactSession.started_at)) + .limit(RECENT_LIMIT) + ) + ).all() + + session_ids = [session.id for session, _ in rows] + + # One GROUP BY across the fetched sessions for event counts by kind. + counts: dict = defaultdict(dict) + if session_ids: + for session_id, kind, count in await db.execute( + select(Event.session_id, Event.kind, func.count(Event.id)) + .where(Event.session_id.in_(session_ids)) + .group_by(Event.session_id, Event.kind) + ): + counts[session_id][kind] = count + + # One bounded query for echoes: top-N spirit utterances per session, + # newest first, via a window function — never one query per session. + echoes: dict = defaultdict(list) + if session_ids: + ranked = ( + select( + Event.session_id, + Event.text, + func.row_number() + .over( + partition_by=Event.session_id, + order_by=desc(Event.created_at), + ) + .label("rn"), + ) + .where( + Event.session_id.in_(session_ids), + Event.kind == "utterance", + Event.text.is_not(None), + Event.payload["kind"].astext.in_(ECHO_PAYLOAD_KINDS), + ) + .subquery() + ) + for session_id, text, _rn in await db.execute( + select(ranked.c.session_id, ranked.c.text, ranked.c.rn).where( + ranked.c.rn <= ECHOES_PER_SESSION + ) + ): + echoes[session_id].append(text) + + return { + "seances": [ + { + "id": str(session.id), + "started_at": session.started_at.isoformat(), + "ended_at": session.ended_at.isoformat() if session.ended_at else None, + "mode": session.mode, + "entity": ( + { + "id": str(entity.id), + "name": entity.name, + "epithet": entity.epithet, + "rarity": entity.rarity_tier, + "visual": entity.visual_profile, + } + if entity is not None + else None + ), + "counts": counts.get(session.id, {}), + "echoes": echoes.get(session.id, []), + } + for session, entity in rows + ] + } diff --git a/backend/tests/test_seances.py b/backend/tests/test_seances.py new file mode 100644 index 0000000..acd8abb --- /dev/null +++ b/backend/tests/test_seances.py @@ -0,0 +1,151 @@ +"""Tests for GET /api/seances/recent — the Ghost Log recap +(usability wave, Workstream C, #7).""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from app.models.contact_session import ContactSession +from app.models.entity import Entity +from app.models.event import Event + + +async def _register(client, username): + resp = await client.post( + "/auth/register", json={"username": username, "password": "spookyspooky"} + ) + assert resp.status_code == 201 + return resp.json()["id"] + + +async def _login(client, username): + resp = await client.post( + "/auth/login", json={"username": username, "password": "spookyspooky"} + ) + assert resp.status_code == 200 + + +def _entity(name, signature): + return Entity( + name=name, + epithet="the Test Wraith", + rarity_tier="rare", + signature=signature, + visual_profile={"hue": 200, "form": "wisp"}, + ) + + +@pytest.mark.asyncio +async def test_unauthenticated_rejected(client): + resp = await client.get("/api/seances/recent") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_only_own_sessions_newest_first_with_counts_and_echoes(client, db_session): + my_id = await _register(client, "seeker1") + other_id = await _register(client, "seeker2") + await _login(client, "seeker1") + + entity = _entity("Testnamed", "sig-aaaa") + db_session.add(entity) + await db_session.flush() + + now = datetime.now(timezone.utc) + older = ContactSession( + user_id=my_id, entity_id=entity.id, mode="evp", started_at=now - timedelta(hours=2) + ) + newer = ContactSession( + user_id=my_id, entity_id=entity.id, mode="wire", started_at=now - timedelta(hours=1) + ) + theirs = ContactSession(user_id=other_id, mode="ouija", started_at=now) + db_session.add_all([older, newer, theirs]) + await db_session.flush() + + # Events on the newer session: 2 questions, 3 anomalies, 4 spirit + # utterances (only the newest 3 should come back as echoes). + events = [ + Event(session_id=newer.id, kind="question", text="who's there?"), + Event(session_id=newer.id, kind="question", text="still there?"), + Event(session_id=newer.id, kind="anomaly", payload={"score": 1}), + Event(session_id=newer.id, kind="anomaly", payload={"score": 2}), + Event(session_id=newer.id, kind="anomaly", payload={"score": 3}), + ] + for i in range(4): + events.append( + Event( + session_id=newer.id, + kind="utterance", + text=f"echo {i}", + payload={"kind": "reply"}, + created_at=now - timedelta(minutes=30 - i), + ) + ) + # An utterance on the OTHER user's session must never leak. + events.append( + Event( + session_id=theirs.id, + kind="utterance", + text="not yours", + payload={"kind": "greeting"}, + ) + ) + db_session.add_all(events) + await db_session.commit() + + resp = await client.get("/api/seances/recent") + assert resp.status_code == 200 + seances = resp.json()["seances"] + + # Only my sessions, newest first. + assert [s["id"] for s in seances] == [str(newer.id), str(older.id)] + + first = seances[0] + assert first["mode"] == "wire" + assert first["counts"] == {"question": 2, "anomaly": 3, "utterance": 4} + assert first["echoes"] == ["echo 3", "echo 2", "echo 1"] + assert first["entity"] == { + "id": str(entity.id), + "name": "Testnamed", + "epithet": "the Test Wraith", + "rarity": "rare", + "visual": {"hue": 200, "form": "wisp"}, + } + + # No cross-user text anywhere in the response. + assert "not yours" not in resp.text + + +@pytest.mark.asyncio +async def test_session_without_entity_does_not_crash(client, db_session): + my_id = await _register(client, "seeker3") + await _login(client, "seeker3") + + session = ContactSession(user_id=my_id, mode="ouija") + db_session.add(session) + await db_session.commit() + + resp = await client.get("/api/seances/recent") + assert resp.status_code == 200 + seances = resp.json()["seances"] + assert len(seances) == 1 + assert seances[0]["entity"] is None + assert seances[0]["counts"] == {} + assert seances[0]["echoes"] == [] + + +@pytest.mark.asyncio +async def test_limited_to_twelve(client, db_session): + my_id = await _register(client, "seeker4") + await _login(client, "seeker4") + + now = datetime.now(timezone.utc) + db_session.add_all( + ContactSession(user_id=my_id, mode="wire", started_at=now - timedelta(minutes=i)) + for i in range(15) + ) + await db_session.commit() + + resp = await client.get("/api/seances/recent") + assert resp.status_code == 200 + assert len(resp.json()["seances"]) == 12 diff --git a/frontend/index.html b/frontend/index.html index e10441b..8abf195 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,7 +5,8 @@ Quantumancy - + + diff --git a/frontend/public/manifest.webmanifest b/frontend/public/manifest.webmanifest new file mode 100644 index 0000000..17f131e --- /dev/null +++ b/frontend/public/manifest.webmanifest @@ -0,0 +1,29 @@ +{ + "name": "Quantumancy", + "short_name": "Quantumancy", + "description": "A self-hosted séance — an instrument for speaking with the dead bandwidth.", + "start_url": "/", + "display": "standalone", + "background_color": "#07070d", + "theme_color": "#07070d", + "icons": [ + { + "src": "/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png", + "purpose": "any" + } + ] +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 48e9182..c145827 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import { CodexEntityPage } from './pages/CodexEntityPage' import { CodexPage } from './pages/CodexPage' import { DevicesPage } from './pages/DevicesPage' import { EnterPage } from './pages/EnterPage' +import { GhostLogPage } from './pages/GhostLogPage' import { InventoryPage } from './pages/InventoryPage' import { LandingPage } from './pages/LandingPage' import { SeancePage } from './pages/SeancePage' @@ -28,6 +29,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/i18n/coverage-check.mjs b/frontend/src/i18n/coverage-check.mjs index efd7d59..06fb07e 100644 --- a/frontend/src/i18n/coverage-check.mjs +++ b/frontend/src/i18n/coverage-check.mjs @@ -39,6 +39,7 @@ const RULES = [ ['codex.rarity.', RARITY], ['codex.sort.', ['recent', 'contacted']], ['codex.form.', ['wisp', 'banshee', 'fairy', 'shade']], + ['log.counts.', ['question', 'anomaly', 'utterance']], ['landing.stats.', ['entities', 'sessions', 'utterances', 'anomalies']], ['landing.modes.', ['radio', 'evp', 'wire', 'ouija']], ['shop.modules.', ['emfArray', 'geophone', 'spiritMic', 'uvIlluminator']], diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 0bbdc3d..8be2658 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -17,7 +17,34 @@ "devices": "devices", "seance": "séance", "enter": "enter", - "logout": "depart" + "logout": "depart", + "log": "log" + }, + "log": { + "title": "GHOST LOG", + "subtitle": "every séance leaves a page behind", + "back": "return to the séance", + "loading": "leafing back through the log…", + "error": "the log will not open — the veil resists tonight", + "empty": "no séances yet — the log waits", + "noEntity": "no one answered", + "counts": { + "question_one": "{{count}} question", + "question_other": "{{count}} questions", + "anomaly_one": "{{count}} anomaly", + "anomaly_other": "{{count}} anomalies", + "utterance_one": "{{count}} utterance", + "utterance_other": "{{count}} utterances" + }, + "time": { + "moments": "moments past", + "minutes_one": "a minute past", + "minutes_other": "{{count}} minutes past", + "hours_one": "an hour past", + "hours_other": "{{count}} hours past", + "days_one": "a day past", + "days_other": "{{count}} days past" + } }, "landing": { "taglines": [ diff --git a/frontend/src/i18n/es.json b/frontend/src/i18n/es.json index 4f4b118..32e3fe4 100644 --- a/frontend/src/i18n/es.json +++ b/frontend/src/i18n/es.json @@ -17,7 +17,34 @@ "devices": "dispositivos", "seance": "sesión", "enter": "entrar", - "logout": "partir" + "logout": "partir", + "log": "registro" + }, + "log": { + "title": "REGISTRO ESPECTRAL", + "subtitle": "cada sesión deja una página tras de sí", + "back": "volver a la sesión", + "loading": "hojeando el registro hacia atrás…", + "error": "el registro no se abre — el velo se resiste esta noche", + "empty": "aún no hay sesiones — el registro espera", + "noEntity": "nadie respondió", + "counts": { + "question_one": "{{count}} pregunta", + "question_other": "{{count}} preguntas", + "anomaly_one": "{{count}} anomalía", + "anomaly_other": "{{count}} anomalías", + "utterance_one": "{{count}} voz", + "utterance_other": "{{count}} voces" + }, + "time": { + "moments": "hace instantes", + "minutes_one": "hace un minuto", + "minutes_other": "hace {{count}} minutos", + "hours_one": "hace una hora", + "hours_other": "hace {{count}} horas", + "days_one": "hace un día", + "days_other": "hace {{count}} días" + } }, "landing": { "taglines": [ diff --git a/frontend/src/pages/GhostLogPage.css b/frontend/src/pages/GhostLogPage.css new file mode 100644 index 0000000..403f34e --- /dev/null +++ b/frontend/src/pages/GhostLogPage.css @@ -0,0 +1,147 @@ +/* Ghost Log page — same tokens as SeancePage.css: + bg #07070d · phosphor #7cffb2 · violet #b26bff. */ + +.ghostlog-page { + min-height: 100vh; + padding: 2rem 1rem 4rem; + background: + radial-gradient(ellipse at 50% 0%, rgba(178, 107, 255, 0.08), transparent 55%), + #07070d; + color: #d3e9db; + font-family: 'IBM Plex Mono', ui-monospace, Menlo, monospace; +} + +.ghostlog-header { + max-width: 680px; + margin: 0 auto 1.6rem; +} + +.ghostlog-back { + color: #b26bff; + text-decoration: none; + font-size: 0.72rem; + letter-spacing: 0.14em; + text-transform: uppercase; + text-shadow: 0 0 10px rgba(178, 107, 255, 0.4); +} + +.ghostlog-back:hover { + text-shadow: 0 0 16px rgba(178, 107, 255, 0.9); +} + +.ghostlog-title { + margin: 0.9rem 0 0.2rem; + font-family: Cinzel, serif; + font-size: 1.6rem; + letter-spacing: 0.18em; + color: #7cffb2; + text-shadow: 0 0 18px rgba(124, 255, 178, 0.35); +} + +.ghostlog-subtitle { + margin: 0; + font-size: 0.78rem; + color: rgba(211, 233, 219, 0.6); +} + +.ghostlog-note { + max-width: 680px; + margin: 2.4rem auto; + font-size: 0.85rem; + color: rgba(211, 233, 219, 0.65); +} + +.ghostlog-empty { + color: #b26bff; + text-shadow: 0 0 12px rgba(178, 107, 255, 0.4); +} + +.ghostlog-list { + max-width: 680px; + margin: 0 auto; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 0.9rem; +} + +.ghostlog-card { + display: flex; + gap: 0.9rem; + padding: 0.9rem 1rem; + background: rgba(16, 16, 26, 0.85); + border: 1px solid rgba(124, 255, 178, 0.18); + border-radius: 6px; +} + +.ghostlog-glyph { + flex: 0 0 auto; +} + +.ghostlog-body { + min-width: 0; + flex: 1; +} + +.ghostlog-who { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; +} + +.ghostlog-name { + font-family: Cinzel, serif; + font-size: 1rem; + color: #7cffb2; +} + +.ghostlog-noone { + color: rgba(211, 233, 219, 0.5); + font-style: italic; +} + +.ghostlog-epithet { + font-size: 0.72rem; + color: #b26bff; +} + +.ghostlog-meta { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.3rem; + font-size: 0.72rem; + letter-spacing: 0.06em; + color: rgba(211, 233, 219, 0.6); +} + +.ghostlog-echoes { + margin-top: 0.55rem; + border-left: 2px solid rgba(178, 107, 255, 0.35); + padding-left: 0.7rem; +} + +.ghostlog-echo { + margin: 0.2rem 0; + font-size: 0.78rem; + color: rgba(124, 255, 178, 0.85); + overflow-wrap: break-word; +} + +/* phone widths */ +@media (max-width: 420px) { + .ghostlog-page { + padding: 1.4rem 0.7rem 3rem; + } + + .ghostlog-card { + padding: 0.7rem 0.7rem; + gap: 0.6rem; + } + + .ghostlog-title { + font-size: 1.3rem; + } +} diff --git a/frontend/src/pages/GhostLogPage.tsx b/frontend/src/pages/GhostLogPage.tsx new file mode 100644 index 0000000..00e491b --- /dev/null +++ b/frontend/src/pages/GhostLogPage.tsx @@ -0,0 +1,153 @@ +// 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 { Link, Navigate } from 'react-router-dom' +import { useTranslation } from 'react-i18next' +import type { TFunction } from 'i18next' +import { GhostGlyph } from '../components/GhostGlyph' +import { useAuth } from '../state/auth' +import type { GhostForm, Mode } from '../lib/types' +import './GhostLogPage.css' + +type LogEntity = { + id: string + name: string + epithet: string + rarity: string + visual: { hue?: number; form?: string } +} + +type LogSeance = { + id: string + started_at: string + ended_at: string | null + mode: Mode | string + entity: LogEntity | null + counts: Record + echoes: string[] +} + +type LoadState = 'loading' | 'error' | 'ready' + +const COUNT_KINDS = ['question', 'anomaly', 'utterance'] as const +const GHOST_FORMS: readonly string[] = ['wisp', 'banshee', 'fairy', 'shade'] + +function relativeTime(iso: string, t: TFunction): string { + const then = new Date(iso).getTime() + if (Number.isNaN(then)) return iso + const minutes = Math.floor((Date.now() - then) / 60_000) + if (minutes < 2) return t('log.time.moments') + if (minutes < 60) return t('log.time.minutes', { count: minutes }) + const hours = Math.floor(minutes / 60) + if (hours < 24) return t('log.time.hours', { count: hours }) + return t('log.time.days', { count: Math.floor(hours / 24) }) +} + +export function GhostLogPage() { + const { t } = useTranslation() + const { user, checking } = useAuth() + const [seances, setSeances] = useState([]) + const [state, setState] = useState('loading') + + useEffect(() => { + if (!user) return + const controller = new AbortController() + fetch('/api/seances/recent', { credentials: 'include', signal: controller.signal }) + .then(async (res) => { + if (!res.ok) throw new Error(`the log answered ${res.status}`) + return (await res.json()) as { seances: LogSeance[] } + }) + .then((data) => { + setSeances(data.seances) + setState('ready') + }) + .catch(() => { + if (!controller.signal.aborted) setState('error') + }) + return () => controller.abort() + }, [user]) + + if (checking) return null + if (!user) return + + return ( +
+
+ + {t('log.back')} + +

{t('log.title')}

+

{t('log.subtitle')}

+
+ + {state === 'loading' && ( +

+ {t('log.loading')} +

+ )} + {state === 'error' &&

{t('log.error')}

} + {state === 'ready' && seances.length === 0 && ( +

{t('log.empty')}

+ )} + + {state === 'ready' && seances.length > 0 && ( +
    + {seances.map((seance) => { + const visual = seance.entity?.visual ?? {} + const form: GhostForm = GHOST_FORMS.includes(visual.form ?? '') + ? (visual.form as GhostForm) + : 'wisp' + const countLine = COUNT_KINDS.filter((kind) => (seance.counts[kind] ?? 0) > 0) + .map((kind) => t(`log.counts.${kind}`, { count: seance.counts[kind] })) + .join(' · ') + return ( +
  • +
    + +
    +
    +
    + {seance.entity ? ( + <> + {seance.entity.name} + {seance.entity.epithet && ( + {seance.entity.epithet} + )} + + ) : ( + + {t('log.noEntity')} + + )} +
    +
    + {relativeTime(seance.started_at, t)} + · + {t(`seance.modes.${seance.mode}`, seance.mode)} + {countLine && ( + <> + · + {countLine} + + )} +
    + {seance.echoes.length > 0 && ( +
    + {seance.echoes.map((echo, i) => ( +

    + » + {echo} +

    + ))} +
    + )} +
    +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/frontend/src/pages/SeancePage.tsx b/frontend/src/pages/SeancePage.tsx index 33d9656..c7ba21b 100644 --- a/frontend/src/pages/SeancePage.tsx +++ b/frontend/src/pages/SeancePage.tsx @@ -269,6 +269,10 @@ function SeanceSession({ username }: { username: string }) { {t('nav.codex')} + + {t('nav.log', { defaultValue: 'log' })} + + {t('nav.inventory', { defaultValue: 'inventory' })}