Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions .context/admin/questionnaire-analytics.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Questionnaire analytics (F8.1)

Version-scoped admin page at `/admin/questionnaires/[id]/analytics?v=[versionId]` — the read-side
view of a version's completed-session data. Three surfaces in tabs: per-question **distributions**,
the completion **funnel**, and **cost** actuals. Reached from the `Analytics` button on the
questionnaire detail page. Read-only, admin-only, master-flag-gated (`APP_QUESTIONNAIRES_ENABLED`).

> **Source of truth:** aggregators in `lib/app/questionnaire/analytics/`, routes under
> `app/api/v1/app/questionnaires/[id]/versions/[vid]/analytics/`, UI in
> `app/admin/questionnaires/[id]/analytics/` + `components/admin/questionnaires/analytics/`.
> Update this doc when those change.

## Scope & filter

One shared filter (`analytics-filters.tsx`) drives all three views through the URL:

- **Date window** — `from`/`to` (`YYYY-MM-DD`), default last 30 days (reuses the platform
`resolveAnalyticsDateRange` so questionnaire and orchestration share one "30 days").
- **Tag filter** — `tagIds` (comma-separated). Restricts the **distributions** view to questions
carrying any selected tag; the funnel and cost views ignore it.
- **Version** — `?v=` (the page owns this, SSR links), like the evaluations/invitations sub-pages.

All aggregations count **non-preview** sessions only (`isPreview = false`).

## The three surfaces

**Distributions** (`distributions.ts`) — per question, a type-appropriate breakdown over the
answers captured in scope:

| Type | Detail |
| ------------------- | -------------------------------------------------------------- |
| single/multi choice | count per option + an "other/unlisted" bucket |
| likert | count per scale point + mean |
| numeric | min/max/mean/median + histogram |
| boolean | true/false counts (custom labels) |
| date | counts bucketed by month |
| **free_text** | **no values** — response rate, avg confidence, provenance only |

Every question also reports answered/unanswered counts, response rate (denominator = sessions in
scope), avg confidence, and the provenance mix (`direct`/`inferred`/`synthesised`/`refined`).
**Free-text answer values are never serialised** — F8.1 stays PII-safe ahead of F8.3.

**Funnel** (`funnel.ts`) — invited → opened → started → completed, with per-stage drop-off,
retention (vs invited), and step conversion. Invited/opened come from invitation timestamps
(`sentAt`/`openedAt`, excluding revoked); started/completed are derived from real sessions matched
to invited respondents by `userId`. **Anonymous (un-invited) sessions** are reported separately
(they enter at "started") so the invite funnel isn't overstated.

**Cost** (`cost.ts`) — total spend split into **respondent runtime** vs **design-time**, a
per-capability breakdown, a daily trend, and the top sessions by spend. Reads the platform
`AiCostLog` ledger via raw SQL over its `metadata` JSON.

## Cost attribution contract

Questionnaire LLM spend is attributed in `AiCostLog.metadata`:

- **Runtime** (live respondent turns) → `metadata.appQuestionnaireSessionId` = the session cuid.
Stamped by the session-bound capabilities (`extract_answer_slots`, `detect_contradictions`,
`refine_answer`, `compose_completion_offer`) and the adaptive selector. F8.1 standardised these
on the `appQuestionnaireSessionId` key (previously a bare `sessionId` in the four capabilities).
- **Design-time** (structure evaluation) → `metadata.versionId`, stamped by `evaluate_structure`.

A version's spend = the ledger rows for its non-preview sessions (runtime) ∪ the rows tagged with
its version id (design-time). The two key sets are disjoint, so there's no double-counting.
`AppQuestionnaireTurn.costUsd` is unrelated to this view — it's the F6.3 budget-enforcement basis
and is left untouched. One-time ingest cost (`extract_questionnaire_structure`, logged before a
version exists) is not version-attributable and is excluded.

## API

All three are `GET`, admin-only, version-scoped (404 on a cross-version id), and accept the shared
query (`from`, `to`, `tagIds`). Rate limiting is the automatic section cap (read-only, no sub-cap).

| Endpoint | Returns |
| ------------------------------------------ | ----------------------------- |
| `…/versions/[vid]/analytics/distributions` | `QuestionDistributionsResult` |
| `…/versions/[vid]/analytics/funnel` | `CompletionFunnelResult` |
| `…/versions/[vid]/analytics/cost` | `QuestionnaireCostResult` |

Endpoint builders: `API.APP.QUESTIONNAIRES.versionAnalytics{Distributions,Funnel,Cost}(id, vid)`.
85 changes: 85 additions & 0 deletions .context/app/planning/features/f8.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
feature: F8.1
title: Admin analytics dashboards
phase: P8 — Admin analytics, exports, anonymous mode
status: in flight
owner: TBD
deps: F4.2 (answer slots), F4.6 (session events), F3.2 (invitations), F6.1 (live turns)
opened: 2026-06-08
plan: .context/app/planning/development-plan.md#f81--admin-analytics-dashboards
docs: .context/admin/questionnaire-analytics.md
---

# F8.1 — Admin analytics dashboards

> Committable tracker for **F8.1**, the opening feature of P8. The admin's read-side view of
> completed-session data, scoped to a version: per-question answer distributions, an
> invitation completion funnel, and per-version cost actuals. Tag-aware filtering. Read-only,
> aggregate-only — no new models, no migration. Gated by `APP_QUESTIONNAIRES_ENABLED`.

## Intent

P1–P7 produced the data: answer slots (F4.2), the session state machine + events (F4.6),
invitations (F3.2), live respondent turns + cost (F6.1/F6.3). F8.1 makes that data
**readable** for the admin without mutating anything: three aggregations over a version's
non-preview sessions, surfaced as a version-scoped `Analytics` tab.

It is deliberately **aggregate-only**: free-text answer _values_ are never surfaced (only
response rate, confidence, provenance), so F8.1 is PII-safe by construction. Full
anonymous-mode hardening across every surface is F8.3.

## Decisions (confirmed with the user)

- **Cost source = `AiCostLog`, made properly attributable** (not an `AppQuestionnaireTurn.costUsd`
shortcut). The four session-bound capabilities already stamped a session link in `metadata`
under a bare `sessionId`; the adaptive selector (via `costLogMetadata`) used the canonical
`appQuestionnaireSessionId`. F8.1 **standardises the four capabilities on
`appQuestionnaireSessionId`** so runtime cost is attributable by one key, and reads
design-time cost via `metadata.versionId` (already stamped by `evaluate-structure`). The two
tag sets are disjoint, so runtime + design-time never double-count. `AppQuestionnaireTurn.costUsd`
is left untouched — it remains the F6.3 budget-enforcement basis. No existing data, so no
backfill concern.
- **Placement = version-scoped tab** under `app/admin/questionnaires/[id]/analytics`, alongside
the existing `invitations` and `evaluations` sub-pages (`?v=` selects the version).
- **Funnel from session reality, not invitation status.** Invited/opened come from
`AppQuestionnaireInvitation` timestamps; started/completed are derived from real sessions
matched to invited respondents by `userId`. Anonymous (un-invited) sessions can't enter the
invite funnel, so they're reported separately (entering at "started").
- **No migration.** `AiCostLog.metadata` is already `Json`; everything else aggregates over
existing models.

## Build shape (branch `feat/F8.1-admin-analytics-dashboards`)

- **Cost attribution** — metadata key standardised to `appQuestionnaireSessionId` in
`lib/app/questionnaire/capabilities/{extract-answer-slots,detect-contradictions,refine-answer,compose-completion-offer}.ts`.
- **Core aggregators** (pure, Prisma at the seam) — `lib/app/questionnaire/analytics/`:
`views.ts` (client-safe types), `query-schema.ts` (`questionnaireAnalyticsQuerySchema` +
`resolveAnalyticsScope`, reusing the platform date-range helpers), `distributions.ts`,
`funnel.ts`, `cost.ts` (raw SQL over `ai_cost_log` JSON metadata), `index.ts`.
- **API routes** (admin-only, master-flag-gated, version-scoped, read-only) —
`app/api/v1/app/questionnaires/[id]/versions/[vid]/analytics/{distributions,funnel,cost}/route.ts`;
endpoint constants in `lib/api/endpoints.ts`.
- **UI** — `app/admin/questionnaires/[id]/analytics/page.tsx` (server, parallel fetch),
`Analytics` link on the detail page, and the client island
`components/admin/questionnaires/analytics/{analytics-view,analytics-filters,question-distribution-panel,completion-funnel-panel,cost-panel}.tsx`
(tabs; recharts for the cost trend; CSS bars elsewhere; reuses `TagChip` + `FieldHelp`).

## Tests

- Unit (`tests/unit/lib/app/questionnaire/analytics/`): distribution math per type incl.
free-text value suppression and tag-filter wiring; funnel stage counting, drop-off math,
anonymous separation, preview exclusion; cost runtime/design split, per-capability merge,
top-session ranking, trend mapping, no-session short-circuit. 16 tests.

## Known limitations / deferred

- One-time ingest cost (`extract-questionnaire-structure`, stamped pre-version) is not
version-attributable and is excluded from the per-version cost view.
- JSON-path cost filters aren't indexed; fine at admin scale, revisit with a GIN index if needed.
- CSV/JSON result exports → F8.2. Cross-surface anonymous-mode PII audit → F8.3.

## Docs and CHANGELOG

- Admin doc: `.context/admin/questionnaire-analytics.md`.
- **No CHANGELOG entry** — the CHANGELOG tracks only the Sunrise platform surface; ConQuest
app routes/models are out of scope.
207 changes: 207 additions & 0 deletions app/admin/questionnaires/[id]/analytics/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import type { Metadata } from 'next';
import Link from 'next/link';
import { notFound } from 'next/navigation';

import { AnalyticsView } from '@/components/admin/questionnaires/analytics/analytics-view';
import { API } from '@/lib/api/endpoints';
import { parseApiResponse, serverFetch } from '@/lib/api/server-fetch';
import { logger } from '@/lib/logging';
import { isQuestionnairesEnabled } from '@/lib/app/questionnaire/feature-flag';
import { getAnalyticsDefaultDateInputs } from '@/lib/app/questionnaire/analytics';
import type {
CompletionFunnelResult,
QuestionDistributionsResult,
QuestionnaireCostResult,
} from '@/lib/app/questionnaire/analytics';
import type { QuestionnaireDetail, TagView, VersionGraphView } from '@/lib/app/questionnaire/views';

export const metadata: Metadata = {
title: 'Analytics',
description: 'Per-question distributions, completion funnel, and cost actuals for a version.',
};

interface PageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ v?: string; from?: string; to?: string; tagIds?: string }>;
}

/** Build the shared analytics query string (date window + tag filter). */
function buildQuery(sp: { from?: string; to?: string; tagIds?: string }): string {
const qs = new URLSearchParams();
if (sp.from) qs.set('from', sp.from);
if (sp.to) qs.set('to', sp.to);
if (sp.tagIds) qs.set('tagIds', sp.tagIds);
return qs.toString() ? `?${qs.toString()}` : '';
}

async function getDetail(id: string): Promise<QuestionnaireDetail | null> {
try {
const res = await serverFetch(API.APP.QUESTIONNAIRES.byId(id));
if (!res.ok) return null;
const body = await parseApiResponse<QuestionnaireDetail>(res);
return body.success ? body.data : null;
} catch (err) {
logger.error('analytics page: detail fetch failed', err);
return null;
}
}

async function getTagVocabulary(id: string, versionId: string): Promise<TagView[]> {
try {
const res = await serverFetch(API.APP.QUESTIONNAIRES.versionGraph(id, versionId));
if (!res.ok) return [];
const body = await parseApiResponse<VersionGraphView>(res);
return body.success ? body.data.tags : [];
} catch (err) {
logger.error('analytics page: tag vocabulary fetch failed', err);
return [];
}
}

async function getDistributions(
id: string,
versionId: string,
query: string
): Promise<QuestionDistributionsResult | null> {
try {
const res = await serverFetch(
`${API.APP.QUESTIONNAIRES.versionAnalyticsDistributions(id, versionId)}${query}`
);
if (!res.ok) return null;
const body = await parseApiResponse<QuestionDistributionsResult>(res);
return body.success ? body.data : null;
} catch (err) {
logger.error('analytics page: distributions fetch failed', err);
return null;
}
}

async function getFunnel(
id: string,
versionId: string,
query: string
): Promise<CompletionFunnelResult | null> {
try {
const res = await serverFetch(
`${API.APP.QUESTIONNAIRES.versionAnalyticsFunnel(id, versionId)}${query}`
);
if (!res.ok) return null;
const body = await parseApiResponse<CompletionFunnelResult>(res);
return body.success ? body.data : null;
} catch (err) {
logger.error('analytics page: funnel fetch failed', err);
return null;
}
}

async function getCost(
id: string,
versionId: string,
query: string
): Promise<QuestionnaireCostResult | null> {
try {
const res = await serverFetch(
`${API.APP.QUESTIONNAIRES.versionAnalyticsCost(id, versionId)}${query}`
);
if (!res.ok) return null;
const body = await parseApiResponse<QuestionnaireCostResult>(res);
return body.success ? body.data : null;
} catch (err) {
logger.error('analytics page: cost fetch failed', err);
return null;
}
}

export default async function QuestionnaireAnalyticsPage({ params, searchParams }: PageProps) {
if (!(await isQuestionnairesEnabled())) notFound();

const { id } = await params;
const sp = await searchParams;

const detail = await getDetail(id);
if (!detail) notFound();

// Version selection mirrors the detail/evaluations pages: `?v=` or the newest.
const selected = detail.versions.find((ver) => ver.id === sp.v) ?? detail.versions[0] ?? null;

const { from: defaultFrom, to: defaultTo } = getAnalyticsDefaultDateInputs();
const filters = {
from: sp.from || defaultFrom,
to: sp.to || defaultTo,
tagIds: (sp.tagIds ?? '').split(',').filter((t) => t.length > 0),
};
const query = buildQuery(sp);

const [tagVocabulary, distributions, funnel, cost] = selected
? await Promise.all([
getTagVocabulary(id, selected.id),
getDistributions(id, selected.id, query),
getFunnel(id, selected.id, query),
getCost(id, selected.id, query),
])
: [[] as TagView[], null, null, null];

return (
<div className="space-y-6">
<nav className="text-muted-foreground text-xs">
<Link href="/admin/questionnaires" className="hover:underline">
Questionnaires
</Link>
{' / '}
<Link href={`/admin/questionnaires/${id}`} className="hover:underline">
{detail.title}
</Link>
{' / '}
<span>Analytics</span>
</nav>

<header className="space-y-1">
<h1 className="text-2xl font-semibold">Analytics</h1>
<p className="text-muted-foreground text-sm">
The read-side view of completed-session data for a version: per-question distributions,
the invitation completion funnel, and cost actuals. Aggregate-only — individual free-text
answers are never shown.
</p>
</header>

{detail.versions.length === 0 || !selected ? (
<p className="text-muted-foreground text-sm italic">This questionnaire has no versions.</p>
) : (
<>
{/* Version selector — SSR links that set ?v= on this sub-route. */}
<div className="flex flex-wrap gap-2 border-b pb-3">
{detail.versions.map((ver) => {
const active = ver.id === selected.id;
return (
<Link
key={ver.id}
href={`/admin/questionnaires/${id}/analytics?v=${ver.id}`}
scroll={false}
className={
active
? 'bg-primary text-primary-foreground rounded-md px-3 py-1.5 text-sm font-medium'
: 'hover:bg-accent rounded-md border px-3 py-1.5 text-sm'
}
>
v{ver.versionNumber}
<span className={active ? 'opacity-80' : 'text-muted-foreground'}>
{' '}
· {ver.status}
</span>
</Link>
);
})}
</div>

<AnalyticsView
tagVocabulary={tagVocabulary}
distributions={distributions}
funnel={funnel}
cost={cost}
filters={filters}
/>
</>
)}
</div>
);
}
7 changes: 7 additions & 0 deletions app/admin/questionnaires/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ export default async function QuestionnaireDetailPage({ params, searchParams }:
<Button asChild variant="outline" size="sm">
<Link href={`/admin/questionnaires/${id}/invitations`}>Invitations</Link>
</Button>
{/* Analytics (F8.1) — read-side completed-session view, scoped to the
selected version. Always available (read-only, no paid work). */}
<Button asChild variant="outline" size="sm">
<Link href={`/admin/questionnaires/${id}/analytics?v=${selected.id}`}>
Analytics
</Link>
</Button>
{/* Design-time evaluation (F5.2) — only when the sub-flag is on (the run route
404s otherwise), scoped to the selected version. */}
{designEvalEnabled && (
Expand Down
Loading