this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add question-centric response view

+688 -61
+265 -38
app/(creator)/forms/[id]/responses/page.tsx
··· 8 8 import { Badge } from "@/components/ui/badge"; 9 9 import { Button } from "@/components/ui/button"; 10 10 import { Card } from "@/components/ui/card"; 11 + import { 12 + AGREEMENT_ANSWER_VALUES, 13 + blockTypeTranslationKeys, 14 + } from "@/lib/blocks"; 11 15 import { getServerAuthSession } from "@/lib/auth"; 12 16 import { AppError } from "@/lib/errors"; 13 17 import { listResponsesForOwnedForm } from "@/lib/forms"; 14 18 import { getRequestI18n } from "@/lib/i18n-server"; 15 19 import { resolveTitle, withTitle } from "@/lib/metadata"; 16 - import { formatDate } from "@/lib/utils"; 20 + import type { QuestionSummary } from "@/lib/form-types"; 21 + import { cn, formatCalendarDate, formatDate } from "@/lib/utils"; 17 22 18 23 async function getResponsesData(id: string) { 19 24 const session = await getServerAuthSession(); ··· 29 34 } 30 35 } 31 36 37 + type ResponseReviewView = "submissions" | "questions"; 38 + 39 + function resolveView(value: string | string[] | undefined): ResponseReviewView { 40 + const resolved = Array.isArray(value) ? value[0] : value; 41 + return resolved === "submissions" ? "submissions" : "questions"; 42 + } 43 + 32 44 export async function generateMetadata({ 33 45 params, 34 46 }: { ··· 45 57 46 58 export default async function ResponsesPage({ 47 59 params, 60 + searchParams, 48 61 }: { 49 62 params: Promise<{ id: string }>; 63 + searchParams: Promise<{ view?: string | string[] }>; 50 64 }) { 51 65 const { locale, t } = await getRequestI18n(); 52 66 const { id } = await params; 53 - const { form, responses } = await getResponsesData(id); 54 - 67 + const { form, responses, questionSummaries } = await getResponsesData(id); 68 + const { view } = await searchParams; 69 + const currentView = resolveView(view); 55 70 const workspaceLabel = 56 71 form.workspace.kind === "personal" 57 72 ? t("workspace.personal") 58 73 : form.workspace.label; 74 + const numberFormatter = new Intl.NumberFormat(locale, { 75 + maximumFractionDigits: 2, 76 + }); 77 + const questionsHref = `/forms/${form.id}/responses`; 78 + const submissionsHref = `/forms/${form.id}/responses?view=submissions`; 79 + 80 + function formatAnswerValue(summary: QuestionSummary, value: string) { 81 + if (summary.block.type === "DATE") { 82 + return formatCalendarDate(value, locale); 83 + } 84 + 85 + return value; 86 + } 87 + 88 + function getOptionLabel(summary: QuestionSummary, value: string) { 89 + if (summary.block.type !== "AGREEMENT") { 90 + return value; 91 + } 92 + 93 + return value === AGREEMENT_ANSWER_VALUES.AGREED 94 + ? t("publicRunner.agree") 95 + : t("publicRunner.doNotAgree"); 96 + } 59 97 60 98 return ( 61 99 <div className="space-y-5"> ··· 69 107 </p> 70 108 </div> 71 109 <div className="flex flex-wrap gap-3 lg:justify-end"> 72 - <Badge> 73 - {t("responses.submissionsCount", { count: responses.length })} 74 - </Badge> 75 110 <Link href={`/forms/${form.id}/edit`}> 76 111 <Button variant="secondary">{t("responses.backToBuilder")}</Button> 77 112 </Link> ··· 79 114 </div> 80 115 </section> 81 116 82 - {responses.length === 0 ? ( 117 + <div className="flex flex-wrap items-center justify-between gap-3"> 118 + <div className="inline-flex h-12 items-center gap-1 rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] p-1"> 119 + <Link 120 + href={questionsHref} 121 + className={cn( 122 + "inline-flex h-full items-center gap-2 rounded-lg px-3 text-sm font-medium transition", 123 + currentView === "questions" 124 + ? "!bg-[var(--ink)] !text-[var(--bg)]" 125 + : "text-[var(--muted)] hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]", 126 + )} 127 + aria-current={currentView === "questions" ? "page" : undefined} 128 + > 129 + {t("responses.viewQuestions")} 130 + </Link> 131 + <Link 132 + href={submissionsHref} 133 + className={cn( 134 + "inline-flex h-full items-center gap-2 rounded-lg px-3 text-sm font-medium transition", 135 + currentView === "submissions" 136 + ? "!bg-[var(--ink)] !text-[var(--bg)]" 137 + : "text-[var(--muted)] hover:bg-[var(--accent-soft)] hover:text-[var(--ink)]", 138 + )} 139 + aria-current={currentView === "submissions" ? "page" : undefined} 140 + > 141 + {t("responses.viewSubmissions")} 142 + </Link> 143 + </div> 144 + <div className="inline-flex h-12 items-center rounded-xl border border-[color:var(--line)] bg-[var(--surface-strong)] p-1"> 145 + <div className="inline-flex h-full items-center rounded-lg px-3 text-sm font-medium text-[var(--ink)]"> 146 + {t("responses.submissionsCount", { count: responses.length })} 147 + </div> 148 + </div> 149 + </div> 150 + 151 + {currentView === "submissions" ? ( 152 + responses.length === 0 ? ( 153 + <EmptyState 154 + eyebrow={t("responses.noSubmissionsEyebrow")} 155 + title={t("responses.noSubmissionsTitle")} 156 + description={t("responses.noSubmissionsDescription")} 157 + action={ 158 + <Link href={`/forms/${form.id}/edit`}> 159 + <Button>{t("responses.openBuilder")}</Button> 160 + </Link> 161 + } 162 + /> 163 + ) : ( 164 + <Card className="overflow-hidden p-0"> 165 + <div className="divide-y divide-[color:var(--line)]"> 166 + {responses.map((response) => ( 167 + <div 168 + key={response.id} 169 + className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4" 170 + > 171 + <div className="min-w-0"> 172 + <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3"> 173 + <h2 className="font-display text-2xl text-[var(--ink)]"> 174 + {t("responses.submissionTitle", { 175 + number: response.submissionNumber, 176 + date: formatDate(response.submittedAt, locale), 177 + })} 178 + </h2> 179 + <p className="text-sm text-[var(--muted)]"> 180 + {t("responses.answersCount", { 181 + count: response.answerCount, 182 + })} 183 + </p> 184 + </div> 185 + </div> 186 + <div className="sm:flex sm:justify-end"> 187 + <Link href={`/forms/${form.id}/responses/${response.id}`}> 188 + <Button size="sm" variant="secondary"> 189 + {t("responses.open")} 190 + <ArrowRight className="size-4" /> 191 + </Button> 192 + </Link> 193 + </div> 194 + </div> 195 + ))} 196 + </div> 197 + </Card> 198 + ) 199 + ) : questionSummaries.length === 0 ? ( 83 200 <EmptyState 84 - eyebrow={t("responses.noSubmissionsEyebrow")} 85 - title={t("responses.noSubmissionsTitle")} 86 - description={t("responses.noSubmissionsDescription")} 201 + eyebrow={t("responses.noQuestionBlocksEyebrow")} 202 + title={t("responses.noQuestionBlocksTitle")} 203 + description={t("responses.noQuestionBlocksDescription")} 87 204 action={ 88 205 <Link href={`/forms/${form.id}/edit`}> 89 206 <Button>{t("responses.openBuilder")}</Button> ··· 91 208 } 92 209 /> 93 210 ) : ( 94 - <Card className="overflow-hidden p-0"> 95 - <div className="divide-y divide-[color:var(--line)]"> 96 - {responses.map((response) => ( 97 - <div 98 - key={response.id} 99 - className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-4" 100 - > 101 - <div className="min-w-0"> 102 - <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-3"> 103 - <h2 className="font-display text-2xl text-[var(--ink)]"> 104 - {t("responses.submissionTitle", { 105 - number: response.submissionNumber, 106 - date: formatDate(response.submittedAt, locale), 211 + <div className="space-y-4"> 212 + {questionSummaries.map((summary) => ( 213 + <Card key={summary.block.id} className="p-6"> 214 + <div className="flex flex-wrap items-center gap-3"> 215 + <Badge>{t(blockTypeTranslationKeys[summary.block.type])}</Badge> 216 + {summary.block.required ? ( 217 + <Badge className="bg-[var(--accent-soft)] text-[var(--accent-ink)]"> 218 + {t("responseDetail.required")} 219 + </Badge> 220 + ) : null} 221 + </div> 222 + 223 + <h2 className="mt-4 font-display text-3xl text-[var(--ink)]"> 224 + {summary.block.title} 225 + </h2> 226 + {summary.block.description ? ( 227 + <p className="mt-3 text-sm leading-7 text-[var(--muted)]"> 228 + {summary.block.description} 229 + </p> 230 + ) : null} 231 + 232 + <div className="mt-6 flex flex-wrap gap-2"> 233 + <Badge className="bg-[var(--surface-strong)] text-[var(--ink)]"> 234 + {t("responses.reachedCount", { count: summary.reachedCount })} 235 + </Badge> 236 + <Badge className="bg-[var(--surface-strong)] text-[var(--ink)]"> 237 + {t("responses.answeredCount", { 238 + count: summary.answeredCount, 239 + })} 240 + </Badge> 241 + <Badge className="bg-[var(--surface-strong)] text-[var(--ink)]"> 242 + {t("responses.emptyCount", { count: summary.emptyCount })} 243 + </Badge> 244 + <Badge className="bg-[var(--surface-strong)] text-[var(--ink)]"> 245 + {t("responses.skippedCount", { count: summary.skippedCount })} 246 + </Badge> 247 + </div> 248 + 249 + {summary.kind === "choice" ? ( 250 + <div className="mt-6 grid gap-3"> 251 + {summary.optionCounts.map((option) => ( 252 + <div 253 + key={option.value} 254 + className="flex items-center justify-between rounded-[16px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]" 255 + > 256 + <span className="font-medium"> 257 + {getOptionLabel(summary, option.value)} 258 + </span> 259 + <span className="text-[var(--muted)]"> 260 + {option.count} 261 + </span> 262 + </div> 263 + ))} 264 + </div> 265 + ) : null} 266 + 267 + {summary.kind === "number" ? ( 268 + <div className="mt-6 grid gap-3 sm:grid-cols-3"> 269 + <div className="rounded-[16px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]"> 270 + <p className="text-[var(--muted)]"> 271 + {t("responses.minValue", { 272 + value: 273 + summary.min === null 274 + ? "—" 275 + : numberFormatter.format(summary.min), 107 276 })} 108 - </h2> 109 - <p className="text-sm text-[var(--muted)]"> 110 - {t("responses.answersCount", { 111 - count: response.answerCount, 277 + </p> 278 + </div> 279 + <div className="rounded-[16px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]"> 280 + <p className="text-[var(--muted)]"> 281 + {t("responses.maxValue", { 282 + value: 283 + summary.max === null 284 + ? "—" 285 + : numberFormatter.format(summary.max), 286 + })} 287 + </p> 288 + </div> 289 + <div className="rounded-[16px] border border-[color:var(--line)] bg-[var(--bg-strong)] px-4 py-3 text-sm text-[var(--ink)]"> 290 + <p className="text-[var(--muted)]"> 291 + {t("responses.averageValue", { 292 + value: 293 + summary.average === null 294 + ? "—" 295 + : numberFormatter.format(summary.average), 112 296 })} 113 297 </p> 114 298 </div> 115 299 </div> 116 - <div className="sm:flex sm:justify-end"> 117 - <Link href={`/forms/${form.id}/responses/${response.id}`}> 118 - <Button size="sm" variant="secondary"> 119 - {t("responses.open")} 120 - <ArrowRight className="size-4" /> 121 - </Button> 122 - </Link> 300 + ) : null} 301 + 302 + {summary.kind === "freeform" ? ( 303 + <div className="mt-6 rounded-[18px] border border-[color:var(--line)] bg-[var(--bg-strong)] p-4"> 304 + <p className="text-sm font-medium text-[var(--ink)]"> 305 + {t("responses.answerList")} 306 + </p> 307 + {summary.answers.length === 0 ? ( 308 + <p className="mt-3 text-sm text-[var(--muted)]"> 309 + {t("responses.noAnswersYet")} 310 + </p> 311 + ) : ( 312 + <div className="mt-3 divide-y divide-[color:var(--line)]"> 313 + {summary.answers.map((answer, index) => ( 314 + <div 315 + key={`${answer.responseId}-${answer.submissionNumber}-${index}`} 316 + className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between" 317 + > 318 + {summary.block.type === "LINK" ? ( 319 + <Link 320 + href={answer.value} 321 + target="_blank" 322 + rel="noreferrer" 323 + className="break-all text-sm leading-7 text-[var(--accent)] hover:underline" 324 + > 325 + {answer.value} 326 + </Link> 327 + ) : ( 328 + <p className="whitespace-pre-wrap break-words text-sm leading-7 text-[var(--ink)]"> 329 + {formatAnswerValue(summary, answer.value)} 330 + </p> 331 + )} 332 + <Link 333 + href={`/forms/${form.id}/responses/${answer.responseId}`} 334 + className="text-xs font-medium text-[var(--muted)] transition hover:text-[var(--ink)]" 335 + > 336 + {t("meta.response", { 337 + number: answer.submissionNumber, 338 + })} 339 + </Link> 340 + </div> 341 + ))} 342 + </div> 343 + )} 123 344 </div> 124 - </div> 125 - ))} 126 - </div> 127 - </Card> 345 + ) : null} 346 + 347 + {summary.kind !== "freeform" && summary.answeredCount === 0 ? ( 348 + <p className="mt-6 text-sm text-[var(--muted)]"> 349 + {t("responses.noAnswersYet")} 350 + </p> 351 + ) : null} 352 + </Card> 353 + ))} 354 + </div> 128 355 )} 129 356 </div> 130 357 );
+40
lib/form-types.ts
··· 104 104 workspace: WorkspaceReference; 105 105 }; 106 106 107 + export type QuestionSummaryOptionCount = { 108 + value: string; 109 + count: number; 110 + }; 111 + 112 + export type QuestionSummaryAnswer = { 113 + responseId: string; 114 + submissionNumber: number; 115 + value: string; 116 + }; 117 + 118 + export type QuestionSummary = { 119 + block: SerializedBlock; 120 + reachedCount: number; 121 + answeredCount: number; 122 + emptyCount: number; 123 + skippedCount: number; 124 + } & ( 125 + | { 126 + kind: "choice"; 127 + optionCounts: QuestionSummaryOptionCount[]; 128 + } 129 + | { 130 + kind: "number"; 131 + min: number | null; 132 + max: number | null; 133 + average: number | null; 134 + } 135 + | { 136 + kind: "freeform"; 137 + answers: QuestionSummaryAnswer[]; 138 + } 139 + ); 140 + 141 + export type ResponseReviewData = { 142 + form: ResponseReviewFormSummary; 143 + responses: ResponseListItem[]; 144 + questionSummaries: QuestionSummary[]; 145 + }; 146 + 107 147 export type ProfileSettingsUserSummary = { 108 148 id: string; 109 149 name: string | null;
+191 -22
lib/forms.ts
··· 4 4 type FormBlock, 5 5 type FormBlockType as PrismaFormBlockType, 6 6 type Prisma, 7 + type Response as PrismaResponse, 7 8 } from "@prisma/client"; 8 9 import { z } from "zod"; 9 10 ··· 39 40 BuilderForm, 40 41 FormListItem, 41 42 PublicForm, 43 + QuestionSummary, 44 + QuestionSummaryAnswer, 42 45 ResponseDetail, 43 46 ResponseListItem, 47 + ResponseReviewData, 44 48 ResponseReviewFormSummary, 45 49 WorkspaceReference, 46 50 } from "@/lib/form-types"; ··· 614 618 })); 615 619 } 616 620 621 + function parseResponseAnswers( 622 + response: PrismaResponse, 623 + ): Record<string, string | string[]> { 624 + return typeof response.answersJson === "object" && 625 + response.answersJson && 626 + !Array.isArray(response.answersJson) 627 + ? (response.answersJson as Record<string, string | string[]>) 628 + : {}; 629 + } 630 + 631 + function parseVisitedBlockIds( 632 + response: PrismaResponse, 633 + fallbackBlocks: SerializedBlock[], 634 + ) { 635 + const parsed = z 636 + .array(snapshotBlockSchema) 637 + .safeParse(response.formSnapshotJson); 638 + 639 + if (!parsed.success) { 640 + return new Set(fallbackBlocks.map((block) => block.id)); 641 + } 642 + 643 + return new Set(parsed.data.map((block) => block.id)); 644 + } 645 + 646 + function hasRecordedAnswer(answer: string | string[] | undefined) { 647 + if (Array.isArray(answer)) { 648 + return answer.length > 0; 649 + } 650 + 651 + return typeof answer === "string" && answer.trim().length > 0; 652 + } 653 + 654 + function aggregateQuestionSummaries( 655 + blocks: SerializedBlock[], 656 + responses: PrismaResponse[], 657 + ): QuestionSummary[] { 658 + const answerableBlocks = blocks.filter((block) => 659 + isQuestionBlock(block.type), 660 + ); 661 + const responseSummaries = responses.map((response, index) => ({ 662 + id: response.id, 663 + submissionNumber: index + 1, 664 + answers: parseResponseAnswers(response), 665 + visitedBlockIds: parseVisitedBlockIds(response, blocks), 666 + })); 667 + 668 + return answerableBlocks.map((block) => { 669 + const reachedResponses = responseSummaries.filter((response) => 670 + response.visitedBlockIds.has(block.id), 671 + ); 672 + const skippedCount = responseSummaries.length - reachedResponses.length; 673 + const answeredResponses = reachedResponses.filter((response) => 674 + hasRecordedAnswer(response.answers[block.id]), 675 + ); 676 + const answeredCount = answeredResponses.length; 677 + const emptyCount = reachedResponses.length - answeredCount; 678 + 679 + if ( 680 + block.type === FORM_BLOCK_TYPES.SINGLE_CHOICE || 681 + block.type === FORM_BLOCK_TYPES.MULTIPLE_CHOICE || 682 + block.type === FORM_BLOCK_TYPES.AGREEMENT 683 + ) { 684 + const initialOptions = 685 + block.type === FORM_BLOCK_TYPES.AGREEMENT 686 + ? [AGREEMENT_ANSWER_VALUES.AGREED, AGREEMENT_ANSWER_VALUES.NOT_AGREED] 687 + : [...(block.config as ChoiceBlockConfig).options]; 688 + const optionCounts = new Map(initialOptions.map((value) => [value, 0])); 689 + 690 + for (const response of answeredResponses) { 691 + const answer = response.answers[block.id]; 692 + const values = Array.isArray(answer) 693 + ? answer 694 + : typeof answer === "string" 695 + ? [answer] 696 + : []; 697 + 698 + for (const value of values) { 699 + optionCounts.set(value, (optionCounts.get(value) ?? 0) + 1); 700 + } 701 + } 702 + 703 + return { 704 + block, 705 + kind: "choice", 706 + reachedCount: reachedResponses.length, 707 + answeredCount, 708 + emptyCount, 709 + skippedCount, 710 + optionCounts: [...optionCounts.entries()].map(([value, count]) => ({ 711 + value, 712 + count, 713 + })), 714 + } satisfies QuestionSummary; 715 + } 716 + 717 + if (block.type === FORM_BLOCK_TYPES.NUMBER) { 718 + const numericAnswers = answeredResponses 719 + .map((response) => response.answers[block.id]) 720 + .filter((answer): answer is string => typeof answer === "string") 721 + .map((answer) => Number(answer)) 722 + .filter((value) => Number.isFinite(value)); 723 + const average = numericAnswers.length 724 + ? numericAnswers.reduce((sum, value) => sum + value, 0) / 725 + numericAnswers.length 726 + : null; 727 + 728 + return { 729 + block, 730 + kind: "number", 731 + reachedCount: reachedResponses.length, 732 + answeredCount, 733 + emptyCount, 734 + skippedCount, 735 + min: numericAnswers.length ? Math.min(...numericAnswers) : null, 736 + max: numericAnswers.length ? Math.max(...numericAnswers) : null, 737 + average, 738 + } satisfies QuestionSummary; 739 + } 740 + 741 + const answers = answeredResponses 742 + .map((response) => ({ 743 + responseId: response.id, 744 + submissionNumber: response.submissionNumber, 745 + value: response.answers[block.id], 746 + })) 747 + .flatMap((response): QuestionSummaryAnswer[] => { 748 + if (Array.isArray(response.value)) { 749 + return response.value.map((value) => ({ 750 + responseId: response.responseId, 751 + submissionNumber: response.submissionNumber, 752 + value, 753 + })); 754 + } 755 + 756 + if (typeof response.value === "string") { 757 + return [ 758 + { 759 + responseId: response.responseId, 760 + submissionNumber: response.submissionNumber, 761 + value: response.value, 762 + }, 763 + ]; 764 + } 765 + 766 + return []; 767 + }) 768 + .reverse(); 769 + 770 + return { 771 + block, 772 + kind: "freeform", 773 + reachedCount: reachedResponses.length, 774 + answeredCount, 775 + emptyCount, 776 + skippedCount, 777 + answers, 778 + } satisfies QuestionSummary; 779 + }); 780 + } 781 + 782 + function buildResponseReviewData( 783 + form: BuilderFormRecord, 784 + responses: PrismaResponse[], 785 + ): ResponseReviewData { 786 + const serializedBlocks = form.blocks.map(serializeBlock); 787 + 788 + return { 789 + form: { 790 + id: form.id, 791 + title: form.title, 792 + workspace: toWorkspaceReference(form), 793 + } satisfies ResponseReviewFormSummary, 794 + responses: responses 795 + .map((response, index) => ({ 796 + id: response.id, 797 + submittedAt: response.submittedAt.toISOString(), 798 + answerCount: Object.keys(parseResponseAnswers(response)).length, 799 + submissionNumber: index + 1, 800 + })) 801 + .reverse() satisfies ResponseListItem[], 802 + questionSummaries: aggregateQuestionSummaries(serializedBlocks, responses), 803 + }; 804 + } 805 + 617 806 export async function listFormsForWorkspace( 618 807 userId: string, 619 808 workspace: ActiveWorkspace, ··· 987 1176 export async function listResponsesForOwnedForm( 988 1177 userId: string, 989 1178 formId: string, 990 - ) { 1179 + ): Promise<ResponseReviewData> { 991 1180 const form = await assertAccessibleForm(userId, formId); 992 1181 const responses = await db.response.findMany({ 993 1182 where: { ··· 996 1185 orderBy: [{ submittedAt: "asc" }, { id: "asc" }], 997 1186 }); 998 1187 999 - return { 1000 - form: { 1001 - id: form.id, 1002 - title: form.title, 1003 - workspace: toWorkspaceReference(form), 1004 - } satisfies ResponseReviewFormSummary, 1005 - responses: responses 1006 - .map((response, index) => ({ 1007 - id: response.id, 1008 - submittedAt: response.submittedAt.toISOString(), 1009 - answerCount: 1010 - typeof response.answersJson === "object" && 1011 - response.answersJson && 1012 - !Array.isArray(response.answersJson) 1013 - ? Object.keys(response.answersJson as Record<string, unknown>) 1014 - .length 1015 - : 0, 1016 - submissionNumber: index + 1, 1017 - })) 1018 - .reverse() satisfies ResponseListItem[], 1019 - }; 1188 + return buildResponseReviewData(form, responses); 1020 1189 } 1021 1190 1022 1191 export async function getOwnedResponseDetail(
+4 -1
lib/metadata.ts
··· 1 1 import type { Metadata } from "next"; 2 2 3 - export function resolveTitle(title: string | null | undefined, fallback: string) { 3 + export function resolveTitle( 4 + title: string | null | undefined, 5 + fallback: string, 6 + ) { 4 7 const normalized = title?.trim(); 5 8 return normalized && normalized.length > 0 ? normalized : fallback; 6 9 }
+14
locales/en.yml
··· 288 288 submit: Submit 289 289 responses: 290 290 submissionsCount: "{count} submissions" 291 + viewSubmissions: By submissions 292 + viewQuestions: By questions 291 293 backToBuilder: Back to builder 292 294 noSubmissionsEyebrow: No submissions yet 293 295 noSubmissionsTitle: Publish the form to start collecting responses. 294 296 noSubmissionsDescription: Only published forms are available on the public route, and every respondent stays anonymous on submission. 297 + noQuestionBlocksEyebrow: No answerable questions 298 + noQuestionBlocksTitle: This form has no questions to summarize yet. 299 + noQuestionBlocksDescription: Add at least one answerable block to review responses by question. 300 + noAnswersYet: No answers yet for this question. 295 301 openBuilder: Open builder 296 302 submissionTitle: "Submission #{number} · {date}" 297 303 answersCount: "{count} answers" 304 + reachedCount: "Reached: {count}" 305 + answeredCount: "Answered: {count}" 306 + emptyCount: "No answer: {count}" 307 + skippedCount: "Skipped by branching: {count}" 308 + minValue: "Min: {value}" 309 + maxValue: "Max: {value}" 310 + averageValue: "Average: {value}" 311 + answerList: Answers 298 312 open: Open 299 313 responseDetail: 300 314 title: "Submission #{number} · {date}"
+14
locales/ru.yml
··· 288 288 submit: Отправить 289 289 responses: 290 290 submissionsCount: "{count} ответов" 291 + viewSubmissions: По ответам 292 + viewQuestions: По вопросам 291 293 backToBuilder: Назад к редактору 292 294 noSubmissionsEyebrow: Пока нет ответов 293 295 noSubmissionsTitle: Опубликуйте форму, чтобы начать собирать ответы. 294 296 noSubmissionsDescription: Только опубликованные формы доступны по публичному URL, и каждый респондент остаётся анонимным при ответе. 297 + noQuestionBlocksEyebrow: Нет вопросов для сводки 298 + noQuestionBlocksTitle: В этой форме пока нет вопросов, которые можно суммировать. 299 + noQuestionBlocksDescription: Добавьте хотя бы один блок с ответом, чтобы просматривать ответы по вопросам. 300 + noAnswersYet: На этот вопрос пока нет ответов. 295 301 openBuilder: Открыть редактор 296 302 submissionTitle: "Ответ №{number} · {date}" 297 303 answersCount: "{count} ответов" 304 + reachedCount: "Дошли: {count}" 305 + answeredCount: "Ответили: {count}" 306 + emptyCount: "Без ответа: {count}" 307 + skippedCount: "Пропущено ветвлением: {count}" 308 + minValue: "Мин: {value}" 309 + maxValue: "Макс: {value}" 310 + averageValue: "Среднее: {value}" 311 + answerList: Ответы 298 312 open: Открыть 299 313 responseDetail: 300 314 title: "Ответ №{number} · {date}"
+2
openspec/changes/add-question-centric-response-views/.openspec.yaml
··· 1 + schema: spec-driven 2 + created: 2026-04-13
+78
openspec/changes/add-question-centric-response-views/design.md
··· 1 + ## Context 2 + 3 + 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. 4 + 5 + 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. 6 + 7 + ## Goals / Non-Goals 8 + 9 + **Goals:** 10 + - Add a second responses-page mode for reviewing answers by question instead of only by submission. 11 + - Preserve the existing response list as the default mode so current workflows do not break. 12 + - Show lightweight aggregate stats for each answerable block using data already stored with submissions. 13 + - Respect branching so question-level summaries reflect only submissions that actually reached a given block. 14 + 15 + **Non-Goals:** 16 + - Replacing the single-response detail page. 17 + - Building advanced analytics, charts, or cross-question reporting. 18 + - Adding new database tables, background jobs, or external analytics dependencies. 19 + - Changing export formats as part of this change. 20 + 21 + ## Decisions 22 + 23 + ### Decision: Keep both response-review modes on the existing responses page 24 + 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. 25 + 26 + **Rationale:** This keeps response review in one place, avoids fragmenting navigation, and lets creators switch mental models without leaving the form context. 27 + 28 + **Alternatives considered:** 29 + - Separate question analytics page: rejected because it adds navigation overhead and duplicates page structure. 30 + - Replacing the current list with question view: rejected because creators still need respondent-by-respondent inspection. 31 + 32 + ### Decision: Aggregate question summaries server-side from saved response data 33 + 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. 34 + 35 + **Rationale:** The required data already exists, so server-side aggregation avoids schema changes and keeps branching semantics consistent with the saved submission route. 36 + 37 + **Alternatives considered:** 38 + - Client-side aggregation from raw response payloads: rejected because it would push too much data-shaping logic into the UI. 39 + - Precomputed database aggregates: rejected because the data volume does not justify added write-time complexity yet. 40 + 41 + ### Decision: Scope question summaries to answerable blocks only 42 + The question-centric view will exclude text-only blocks and summarize only blocks that can receive answers. 43 + 44 + **Rationale:** Text blocks are context, not questions. Including them in the question list would add noise and weaken the usefulness of the view. 45 + 46 + **Alternatives considered:** 47 + - Showing all blocks including text sections: rejected because it makes question review harder to scan. 48 + 49 + ### Decision: Use type-specific lightweight summaries 50 + Each question block type will expose a summary shape that fits the data: 51 + - single choice / agreement: per-option counts 52 + - multiple choice: per-option counts across submissions 53 + - number: answered count plus min, max, and average 54 + - short text / long text / link / date: answered count plus a list of submitted answers 55 + 56 + Each summary will also track high-level participation counts such as reached, answered, and skipped-by-branching where possible. 57 + 58 + **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. 59 + 60 + **Alternatives considered:** 61 + - One generic summary format for every block: rejected because it would be too weak for numeric and choice questions. 62 + - Full charts and advanced analytics: rejected as out of scope for this change. 63 + 64 + ## Risks / Trade-offs 65 + 66 + - [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. 67 + - [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. 68 + - [Branching semantics can be confusing when a question was never reached] → Explicitly distinguish reached, answered, and skipped-by-branching counts in the summary model. 69 + - [Different block types need different summary UI] → Centralize summary shaping in server-side helpers so rendering stays predictable and testable. 70 + 71 + ## Migration Plan 72 + 73 + 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. 74 + 75 + ## Open Questions 76 + 77 + - Should free-text style questions show the full answer list immediately or a limited preview with a “show more” affordance? 78 + - 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?
+24
openspec/changes/add-question-centric-response-views/proposal.md
··· 1 + ## Why 2 + 3 + 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. 4 + 5 + ## What Changes 6 + 7 + - Add a second responses view that organizes data by question instead of by submission. 8 + - Keep the existing response list as one of the available views so creators can continue reviewing individual submissions. 9 + - 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. 10 + - Let creators drill into the answers for a selected question from the question-centric view. 11 + 12 + ## Capabilities 13 + 14 + ### New Capabilities 15 + <!-- None. --> 16 + 17 + ### Modified Capabilities 18 + - `response-review`: expand response review from submission-only inspection to include question-centric review and aggregate answer summaries. 19 + 20 + ## Impact 21 + 22 + - Affected areas: responses page UI, response review data shaping, localized copy, and metadata for the responses experience. 23 + - Likely code changes in `app/(creator)/forms/[id]/responses/page.tsx`, response review components, and `lib/forms.ts` aggregation helpers. 24 + - No new external dependencies expected.
+39
openspec/changes/add-question-centric-response-views/specs/response-review/spec.md
··· 1 + ## ADDED Requirements 2 + 3 + ### Requirement: Creator can switch response review between submissions and questions 4 + 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. 5 + 6 + #### Scenario: Creator opens responses for a form 7 + - **WHEN** an authenticated creator opens the responses view for a form they can access 8 + - **THEN** the system shows the submission-centric list by default and offers a control to switch to question-centric review 9 + 10 + #### Scenario: Creator switches to question-centric review 11 + - **WHEN** an authenticated creator selects the question-centric responses mode 12 + - **THEN** the system shows answerable form blocks in form order instead of individual submissions 13 + 14 + #### Scenario: Creator switches back to submission review 15 + - **WHEN** an authenticated creator returns to the submission-centric responses mode after viewing question-centric review 16 + - **THEN** the system shows the existing submission list again 17 + 18 + ### Requirement: Creator can inspect aggregate answers for a question 19 + The system SHALL allow an authenticated creator to inspect a question-level summary for each answerable block using stored submissions that reached that block. 20 + 21 + #### Scenario: Creator reviews a choice question 22 + - **WHEN** an authenticated creator views a single-choice, multiple-choice, or agreement block in question-centric review 23 + - **THEN** the system shows aggregate counts for the available answer options based on submissions that reached that block 24 + 25 + #### Scenario: Creator reviews a numeric question 26 + - **WHEN** an authenticated creator views a number block in question-centric review 27 + - **THEN** the system shows aggregate numeric summary information derived from submitted answers for that block 28 + 29 + #### Scenario: Creator reviews a freeform answer question 30 + - **WHEN** an authenticated creator views a short text, long text, link, or date block in question-centric review 31 + - **THEN** the system shows the submitted answers for that block together with summary counts for answered responses 32 + 33 + #### Scenario: Creator reviews a branched question 34 + - **WHEN** an authenticated creator views a question that is skipped for some submissions because of branching 35 + - **THEN** the system distinguishes submissions that reached the block from submissions that skipped it by route 36 + 37 + #### Scenario: Creator reviews a question with no submitted answers 38 + - **WHEN** an authenticated creator views a question that has no answers yet 39 + - **THEN** the system shows an empty summary state for that question instead of failing or hiding the block
+17
openspec/changes/add-question-centric-response-views/tasks.md
··· 1 + ## 1. Response review data shaping 2 + 3 + - [x] 1.1 Add server-side response review helpers that aggregate question-level summaries from stored answers and visited-block snapshots 4 + - [x] 1.2 Extend the responses page data payload to return both submission-centric data and question-centric summary data for answerable blocks 5 + - [x] 1.3 Cover branching-aware aggregation cases such as reached, answered, skipped-by-branching, and empty-answer states 6 + 7 + ## 2. Responses page UI 8 + 9 + - [x] 2.1 Add a view switcher on the responses page for submission-centric and question-centric review modes 10 + - [x] 2.2 Keep the existing submission list behavior intact as the default mode 11 + - [x] 2.3 Implement the question-centric list UI in form order with type-specific summary rendering for choice, numeric, and freeform blocks 12 + 13 + ## 3. Copy, metadata, and validation 14 + 15 + - [x] 3.1 Add localized labels and empty states for the new response review mode and per-question summaries 16 + - [x] 3.2 Update responses-page metadata and supporting presentation details to reflect the new review experience 17 + - [x] 3.3 Verify the new view against forms with no responses, optional questions, and branched routes