);
diff --git a/lib/form-types.ts b/lib/form-types.ts
index cded2a3..e3eb9e2 100644
--- a/lib/form-types.ts
+++ b/lib/form-types.ts
@@ -104,6 +104,46 @@ export type ResponseReviewFormSummary = {
workspace: WorkspaceReference;
};
+export type QuestionSummaryOptionCount = {
+ value: string;
+ count: number;
+};
+
+export type QuestionSummaryAnswer = {
+ responseId: string;
+ submissionNumber: number;
+ value: string;
+};
+
+export type QuestionSummary = {
+ block: SerializedBlock;
+ reachedCount: number;
+ answeredCount: number;
+ emptyCount: number;
+ skippedCount: number;
+} & (
+ | {
+ kind: "choice";
+ optionCounts: QuestionSummaryOptionCount[];
+ }
+ | {
+ kind: "number";
+ min: number | null;
+ max: number | null;
+ average: number | null;
+ }
+ | {
+ kind: "freeform";
+ answers: QuestionSummaryAnswer[];
+ }
+);
+
+export type ResponseReviewData = {
+ form: ResponseReviewFormSummary;
+ responses: ResponseListItem[];
+ questionSummaries: QuestionSummary[];
+};
+
export type ProfileSettingsUserSummary = {
id: string;
name: string | null;
diff --git a/lib/forms.ts b/lib/forms.ts
index 0cf6078..da394f9 100644
--- a/lib/forms.ts
+++ b/lib/forms.ts
@@ -4,6 +4,7 @@ import {
type FormBlock,
type FormBlockType as PrismaFormBlockType,
type Prisma,
+ type Response as PrismaResponse,
} from "@prisma/client";
import { z } from "zod";
@@ -39,8 +40,11 @@ import type {
BuilderForm,
FormListItem,
PublicForm,
+ QuestionSummary,
+ QuestionSummaryAnswer,
ResponseDetail,
ResponseListItem,
+ ResponseReviewData,
ResponseReviewFormSummary,
WorkspaceReference,
} from "@/lib/form-types";
@@ -614,6 +618,191 @@ function parseSnapshotBlocks(response: ResponseRecord): SerializedBlock[] {
}));
}
+function parseResponseAnswers(
+ response: PrismaResponse,
+): Record {
+ return typeof response.answersJson === "object" &&
+ response.answersJson &&
+ !Array.isArray(response.answersJson)
+ ? (response.answersJson as Record)
+ : {};
+}
+
+function parseVisitedBlockIds(
+ response: PrismaResponse,
+ fallbackBlocks: SerializedBlock[],
+) {
+ const parsed = z
+ .array(snapshotBlockSchema)
+ .safeParse(response.formSnapshotJson);
+
+ if (!parsed.success) {
+ return new Set(fallbackBlocks.map((block) => block.id));
+ }
+
+ return new Set(parsed.data.map((block) => block.id));
+}
+
+function hasRecordedAnswer(answer: string | string[] | undefined) {
+ if (Array.isArray(answer)) {
+ return answer.length > 0;
+ }
+
+ return typeof answer === "string" && answer.trim().length > 0;
+}
+
+function aggregateQuestionSummaries(
+ blocks: SerializedBlock[],
+ responses: PrismaResponse[],
+): QuestionSummary[] {
+ const answerableBlocks = blocks.filter((block) =>
+ isQuestionBlock(block.type),
+ );
+ const responseSummaries = responses.map((response, index) => ({
+ id: response.id,
+ submissionNumber: index + 1,
+ answers: parseResponseAnswers(response),
+ visitedBlockIds: parseVisitedBlockIds(response, blocks),
+ }));
+
+ return answerableBlocks.map((block) => {
+ const reachedResponses = responseSummaries.filter((response) =>
+ response.visitedBlockIds.has(block.id),
+ );
+ const skippedCount = responseSummaries.length - reachedResponses.length;
+ const answeredResponses = reachedResponses.filter((response) =>
+ hasRecordedAnswer(response.answers[block.id]),
+ );
+ const answeredCount = answeredResponses.length;
+ const emptyCount = reachedResponses.length - answeredCount;
+
+ if (
+ block.type === FORM_BLOCK_TYPES.SINGLE_CHOICE ||
+ block.type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE ||
+ block.type === FORM_BLOCK_TYPES.AGREEMENT
+ ) {
+ const initialOptions =
+ block.type === FORM_BLOCK_TYPES.AGREEMENT
+ ? [AGREEMENT_ANSWER_VALUES.AGREED, AGREEMENT_ANSWER_VALUES.NOT_AGREED]
+ : [...(block.config as ChoiceBlockConfig).options];
+ const optionCounts = new Map(initialOptions.map((value) => [value, 0]));
+
+ for (const response of answeredResponses) {
+ const answer = response.answers[block.id];
+ const values = Array.isArray(answer)
+ ? answer
+ : typeof answer === "string"
+ ? [answer]
+ : [];
+
+ for (const value of values) {
+ optionCounts.set(value, (optionCounts.get(value) ?? 0) + 1);
+ }
+ }
+
+ return {
+ block,
+ kind: "choice",
+ reachedCount: reachedResponses.length,
+ answeredCount,
+ emptyCount,
+ skippedCount,
+ optionCounts: [...optionCounts.entries()].map(([value, count]) => ({
+ value,
+ count,
+ })),
+ } satisfies QuestionSummary;
+ }
+
+ if (block.type === FORM_BLOCK_TYPES.NUMBER) {
+ const numericAnswers = answeredResponses
+ .map((response) => response.answers[block.id])
+ .filter((answer): answer is string => typeof answer === "string")
+ .map((answer) => Number(answer))
+ .filter((value) => Number.isFinite(value));
+ const average = numericAnswers.length
+ ? numericAnswers.reduce((sum, value) => sum + value, 0) /
+ numericAnswers.length
+ : null;
+
+ return {
+ block,
+ kind: "number",
+ reachedCount: reachedResponses.length,
+ answeredCount,
+ emptyCount,
+ skippedCount,
+ min: numericAnswers.length ? Math.min(...numericAnswers) : null,
+ max: numericAnswers.length ? Math.max(...numericAnswers) : null,
+ average,
+ } satisfies QuestionSummary;
+ }
+
+ const answers = answeredResponses
+ .map((response) => ({
+ responseId: response.id,
+ submissionNumber: response.submissionNumber,
+ value: response.answers[block.id],
+ }))
+ .flatMap((response): QuestionSummaryAnswer[] => {
+ if (Array.isArray(response.value)) {
+ return response.value.map((value) => ({
+ responseId: response.responseId,
+ submissionNumber: response.submissionNumber,
+ value,
+ }));
+ }
+
+ if (typeof response.value === "string") {
+ return [
+ {
+ responseId: response.responseId,
+ submissionNumber: response.submissionNumber,
+ value: response.value,
+ },
+ ];
+ }
+
+ return [];
+ })
+ .reverse();
+
+ return {
+ block,
+ kind: "freeform",
+ reachedCount: reachedResponses.length,
+ answeredCount,
+ emptyCount,
+ skippedCount,
+ answers,
+ } satisfies QuestionSummary;
+ });
+}
+
+function buildResponseReviewData(
+ form: BuilderFormRecord,
+ responses: PrismaResponse[],
+): ResponseReviewData {
+ const serializedBlocks = form.blocks.map(serializeBlock);
+
+ return {
+ form: {
+ id: form.id,
+ title: form.title,
+ workspace: toWorkspaceReference(form),
+ } satisfies ResponseReviewFormSummary,
+ responses: responses
+ .map((response, index) => ({
+ id: response.id,
+ submittedAt: response.submittedAt.toISOString(),
+ answerCount: Object.keys(parseResponseAnswers(response)).length,
+ submissionNumber: index + 1,
+ }))
+ .reverse() satisfies ResponseListItem[],
+ questionSummaries: aggregateQuestionSummaries(serializedBlocks, responses),
+ };
+}
+
export async function listFormsForWorkspace(
userId: string,
workspace: ActiveWorkspace,
@@ -987,7 +1176,7 @@ export async function createAnonymousResponse(slug: string, payload: unknown) {
export async function listResponsesForOwnedForm(
userId: string,
formId: string,
-) {
+): Promise {
const form = await assertAccessibleForm(userId, formId);
const responses = await db.response.findMany({
where: {
@@ -996,27 +1185,7 @@ export async function listResponsesForOwnedForm(
orderBy: [{ submittedAt: "asc" }, { id: "asc" }],
});
- return {
- form: {
- id: form.id,
- title: form.title,
- workspace: toWorkspaceReference(form),
- } satisfies ResponseReviewFormSummary,
- responses: responses
- .map((response, index) => ({
- id: response.id,
- submittedAt: response.submittedAt.toISOString(),
- answerCount:
- typeof response.answersJson === "object" &&
- response.answersJson &&
- !Array.isArray(response.answersJson)
- ? Object.keys(response.answersJson as Record)
- .length
- : 0,
- submissionNumber: index + 1,
- }))
- .reverse() satisfies ResponseListItem[],
- };
+ return buildResponseReviewData(form, responses);
}
export async function getOwnedResponseDetail(
diff --git a/lib/metadata.ts b/lib/metadata.ts
index e47ce2a..1db360c 100644
--- a/lib/metadata.ts
+++ b/lib/metadata.ts
@@ -1,6 +1,9 @@
import type { Metadata } from "next";
-export function resolveTitle(title: string | null | undefined, fallback: string) {
+export function resolveTitle(
+ title: string | null | undefined,
+ fallback: string,
+) {
const normalized = title?.trim();
return normalized && normalized.length > 0 ? normalized : fallback;
}
diff --git a/locales/en.yml b/locales/en.yml
index b33b3ed..1458623 100644
--- a/locales/en.yml
+++ b/locales/en.yml
@@ -288,13 +288,27 @@ publicRunner:
submit: Submit
responses:
submissionsCount: "{count} submissions"
+ viewSubmissions: By submissions
+ viewQuestions: By questions
backToBuilder: Back to builder
noSubmissionsEyebrow: No submissions yet
noSubmissionsTitle: Publish the form to start collecting responses.
noSubmissionsDescription: Only published forms are available on the public route, and every respondent stays anonymous on submission.
+ noQuestionBlocksEyebrow: No answerable questions
+ noQuestionBlocksTitle: This form has no questions to summarize yet.
+ noQuestionBlocksDescription: Add at least one answerable block to review responses by question.
+ noAnswersYet: No answers yet for this question.
openBuilder: Open builder
submissionTitle: "Submission #{number} · {date}"
answersCount: "{count} answers"
+ reachedCount: "Reached: {count}"
+ answeredCount: "Answered: {count}"
+ emptyCount: "No answer: {count}"
+ skippedCount: "Skipped by branching: {count}"
+ minValue: "Min: {value}"
+ maxValue: "Max: {value}"
+ averageValue: "Average: {value}"
+ answerList: Answers
open: Open
responseDetail:
title: "Submission #{number} · {date}"
diff --git a/locales/ru.yml b/locales/ru.yml
index 634719d..0b8a0f0 100644
--- a/locales/ru.yml
+++ b/locales/ru.yml
@@ -288,13 +288,27 @@ publicRunner:
submit: Отправить
responses:
submissionsCount: "{count} ответов"
+ viewSubmissions: По ответам
+ viewQuestions: По вопросам
backToBuilder: Назад к редактору
noSubmissionsEyebrow: Пока нет ответов
noSubmissionsTitle: Опубликуйте форму, чтобы начать собирать ответы.
noSubmissionsDescription: Только опубликованные формы доступны по публичному URL, и каждый респондент остаётся анонимным при ответе.
+ noQuestionBlocksEyebrow: Нет вопросов для сводки
+ noQuestionBlocksTitle: В этой форме пока нет вопросов, которые можно суммировать.
+ noQuestionBlocksDescription: Добавьте хотя бы один блок с ответом, чтобы просматривать ответы по вопросам.
+ noAnswersYet: На этот вопрос пока нет ответов.
openBuilder: Открыть редактор
submissionTitle: "Ответ №{number} · {date}"
answersCount: "{count} ответов"
+ reachedCount: "Дошли: {count}"
+ answeredCount: "Ответили: {count}"
+ emptyCount: "Без ответа: {count}"
+ skippedCount: "Пропущено ветвлением: {count}"
+ minValue: "Мин: {value}"
+ maxValue: "Макс: {value}"
+ averageValue: "Среднее: {value}"
+ answerList: Ответы
open: Открыть
responseDetail:
title: "Ответ №{number} · {date}"
diff --git a/openspec/changes/add-question-centric-response-views/.openspec.yaml b/openspec/changes/add-question-centric-response-views/.openspec.yaml
new file mode 100644
index 0000000..e14f322
--- /dev/null
+++ b/openspec/changes/add-question-centric-response-views/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-04-13
diff --git a/openspec/changes/add-question-centric-response-views/design.md b/openspec/changes/add-question-centric-response-views/design.md
new file mode 100644
index 0000000..d58a4f1
--- /dev/null
+++ b/openspec/changes/add-question-centric-response-views/design.md
@@ -0,0 +1,78 @@
+## Context
+
+The current response review experience is organized around individual submissions. Creators can open the responses page, inspect each submission, and drill into a single response, but they cannot review a form question as a unit or understand answer patterns without manually scanning many submissions.
+
+This project already stores enough data to support question-centric review. Each saved response includes `answersJson` plus a snapshot of the visited blocks for that submission, which means the system can distinguish between blocks that were visited, blocks skipped by branching, and blocks left unanswered while still preserving form-order context.
+
+## Goals / Non-Goals
+
+**Goals:**
+- Add a second responses-page mode for reviewing answers by question instead of only by submission.
+- Preserve the existing response list as the default mode so current workflows do not break.
+- Show lightweight aggregate stats for each answerable block using data already stored with submissions.
+- Respect branching so question-level summaries reflect only submissions that actually reached a given block.
+
+**Non-Goals:**
+- Replacing the single-response detail page.
+- Building advanced analytics, charts, or cross-question reporting.
+- Adding new database tables, background jobs, or external analytics dependencies.
+- Changing export formats as part of this change.
+
+## Decisions
+
+### Decision: Keep both response-review modes on the existing responses page
+The responses page will expose a view switcher with two modes: the current submission list and a new question-centric list. Submission view remains the default.
+
+**Rationale:** This keeps response review in one place, avoids fragmenting navigation, and lets creators switch mental models without leaving the form context.
+
+**Alternatives considered:**
+- Separate question analytics page: rejected because it adds navigation overhead and duplicates page structure.
+- Replacing the current list with question view: rejected because creators still need respondent-by-respondent inspection.
+
+### Decision: Aggregate question summaries server-side from saved response data
+Question-level summaries will be computed in server code from the form blocks, each response's `answersJson`, and each response's saved visited-block snapshot.
+
+**Rationale:** The required data already exists, so server-side aggregation avoids schema changes and keeps branching semantics consistent with the saved submission route.
+
+**Alternatives considered:**
+- Client-side aggregation from raw response payloads: rejected because it would push too much data-shaping logic into the UI.
+- Precomputed database aggregates: rejected because the data volume does not justify added write-time complexity yet.
+
+### Decision: Scope question summaries to answerable blocks only
+The question-centric view will exclude text-only blocks and summarize only blocks that can receive answers.
+
+**Rationale:** Text blocks are context, not questions. Including them in the question list would add noise and weaken the usefulness of the view.
+
+**Alternatives considered:**
+- Showing all blocks including text sections: rejected because it makes question review harder to scan.
+
+### Decision: Use type-specific lightweight summaries
+Each question block type will expose a summary shape that fits the data:
+- single choice / agreement: per-option counts
+- multiple choice: per-option counts across submissions
+- number: answered count plus min, max, and average
+- short text / long text / link / date: answered count plus a list of submitted answers
+
+Each summary will also track high-level participation counts such as reached, answered, and skipped-by-branching where possible.
+
+**Rationale:** Creators want quick insight first, not full analytics infrastructure. Type-specific summaries provide immediate value while staying implementable within the existing response-review stack.
+
+**Alternatives considered:**
+- One generic summary format for every block: rejected because it would be too weak for numeric and choice questions.
+- Full charts and advanced analytics: rejected as out of scope for this change.
+
+## Risks / Trade-offs
+
+- [Large text-answer volumes may make question view heavy] → Start with a simple list and keep the view server-rendered; add truncation or pagination later if real usage demands it.
+- [Aggregating from saved response snapshots may increase page load cost on forms with many responses] → Keep the first version simple and use one aggregation pass over responses; revisit optimization only if performance becomes an issue.
+- [Branching semantics can be confusing when a question was never reached] → Explicitly distinguish reached, answered, and skipped-by-branching counts in the summary model.
+- [Different block types need different summary UI] → Centralize summary shaping in server-side helpers so rendering stays predictable and testable.
+
+## Migration Plan
+
+No database migration is required. Deploy as an additive UI and server-data change. If the new question view causes issues, fallback is straightforward: hide the view switcher and stop rendering the aggregated data path while keeping the existing submission review intact.
+
+## Open Questions
+
+- Should free-text style questions show the full answer list immediately or a limited preview with a “show more” affordance?
+- Do we want the selected response-review mode to persist per user or form, or is per-page default state sufficient for the first release?
diff --git a/openspec/changes/add-question-centric-response-views/proposal.md b/openspec/changes/add-question-centric-response-views/proposal.md
new file mode 100644
index 0000000..2540427
--- /dev/null
+++ b/openspec/changes/add-question-centric-response-views/proposal.md
@@ -0,0 +1,24 @@
+## Why
+
+Creators can currently review submissions only as a response-by-response list. That works for inspecting individual respondents, but it makes it hard to answer question-level product questions such as which options are selected most often, how many respondents skipped an optional question, or what the overall distribution of answers looks like.
+
+## What Changes
+
+- Add a second responses view that organizes data by question instead of by submission.
+- Keep the existing response list as one of the available views so creators can continue reviewing individual submissions.
+- Show per-question answer summaries and lightweight stats that match the block type, such as choice counts, answer totals, and completion or empty-state counts where relevant.
+- Let creators drill into the answers for a selected question from the question-centric view.
+
+## Capabilities
+
+### New Capabilities
+
+
+### Modified Capabilities
+- `response-review`: expand response review from submission-only inspection to include question-centric review and aggregate answer summaries.
+
+## Impact
+
+- Affected areas: responses page UI, response review data shaping, localized copy, and metadata for the responses experience.
+- Likely code changes in `app/(creator)/forms/[id]/responses/page.tsx`, response review components, and `lib/forms.ts` aggregation helpers.
+- No new external dependencies expected.
diff --git a/openspec/changes/add-question-centric-response-views/specs/response-review/spec.md b/openspec/changes/add-question-centric-response-views/specs/response-review/spec.md
new file mode 100644
index 0000000..ad989ab
--- /dev/null
+++ b/openspec/changes/add-question-centric-response-views/specs/response-review/spec.md
@@ -0,0 +1,39 @@
+## ADDED Requirements
+
+### Requirement: Creator can switch response review between submissions and questions
+The system SHALL allow an authenticated creator to switch the responses page for an accessible form between a submission-centric list and a question-centric list while keeping submission review as the default mode.
+
+#### Scenario: Creator opens responses for a form
+- **WHEN** an authenticated creator opens the responses view for a form they can access
+- **THEN** the system shows the submission-centric list by default and offers a control to switch to question-centric review
+
+#### Scenario: Creator switches to question-centric review
+- **WHEN** an authenticated creator selects the question-centric responses mode
+- **THEN** the system shows answerable form blocks in form order instead of individual submissions
+
+#### Scenario: Creator switches back to submission review
+- **WHEN** an authenticated creator returns to the submission-centric responses mode after viewing question-centric review
+- **THEN** the system shows the existing submission list again
+
+### Requirement: Creator can inspect aggregate answers for a question
+The system SHALL allow an authenticated creator to inspect a question-level summary for each answerable block using stored submissions that reached that block.
+
+#### Scenario: Creator reviews a choice question
+- **WHEN** an authenticated creator views a single-choice, multiple-choice, or agreement block in question-centric review
+- **THEN** the system shows aggregate counts for the available answer options based on submissions that reached that block
+
+#### Scenario: Creator reviews a numeric question
+- **WHEN** an authenticated creator views a number block in question-centric review
+- **THEN** the system shows aggregate numeric summary information derived from submitted answers for that block
+
+#### Scenario: Creator reviews a freeform answer question
+- **WHEN** an authenticated creator views a short text, long text, link, or date block in question-centric review
+- **THEN** the system shows the submitted answers for that block together with summary counts for answered responses
+
+#### Scenario: Creator reviews a branched question
+- **WHEN** an authenticated creator views a question that is skipped for some submissions because of branching
+- **THEN** the system distinguishes submissions that reached the block from submissions that skipped it by route
+
+#### Scenario: Creator reviews a question with no submitted answers
+- **WHEN** an authenticated creator views a question that has no answers yet
+- **THEN** the system shows an empty summary state for that question instead of failing or hiding the block
diff --git a/openspec/changes/add-question-centric-response-views/tasks.md b/openspec/changes/add-question-centric-response-views/tasks.md
new file mode 100644
index 0000000..c3de241
--- /dev/null
+++ b/openspec/changes/add-question-centric-response-views/tasks.md
@@ -0,0 +1,17 @@
+## 1. Response review data shaping
+
+- [x] 1.1 Add server-side response review helpers that aggregate question-level summaries from stored answers and visited-block snapshots
+- [x] 1.2 Extend the responses page data payload to return both submission-centric data and question-centric summary data for answerable blocks
+- [x] 1.3 Cover branching-aware aggregation cases such as reached, answered, skipped-by-branching, and empty-answer states
+
+## 2. Responses page UI
+
+- [x] 2.1 Add a view switcher on the responses page for submission-centric and question-centric review modes
+- [x] 2.2 Keep the existing submission list behavior intact as the default mode
+- [x] 2.3 Implement the question-centric list UI in form order with type-specific summary rendering for choice, numeric, and freeform blocks
+
+## 3. Copy, metadata, and validation
+
+- [x] 3.1 Add localized labels and empty states for the new response review mode and per-question summaries
+- [x] 3.2 Update responses-page metadata and supporting presentation details to reflect the new review experience
+- [x] 3.3 Verify the new view against forms with no responses, optional questions, and branched routes