Usability wave Workstream C (#5, #7): - manifest.webmanifest with existing 192/512/180 icons, linked in index.html with theme-color aligned to #07070d; no service worker. - GET /api/seances/recent: last 12 of the seeker's own séances with entity, per-kind event counts (one GROUP BY) and up to 3 spirit echoes (one windowed query) — no per-session N+1. - /log Ghost Log page: cards with GhostGlyph, relative in-fiction timestamps, counts and echo lines; LOG link in the séance topbar; en/es i18n parity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
110 lines
3.7 KiB
Python
110 lines
3.7 KiB
Python
"""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
|
|
]
|
|
}
|