diff --git a/app/export/submission/page.tsx b/app/export/submission/page.tsx
new file mode 100644
index 0000000..cbc6ee0
--- /dev/null
+++ b/app/export/submission/page.tsx
@@ -0,0 +1,12 @@
+import { ExportPanel } from "@/components/ExportPanel";
+import { PageHeader } from "@/components/PageHeader";
+import { SiteShell } from "@/components/SiteShell";
+
+export default function SubmissionExportPage() {
+ return (
+
+
+
+
+ );
+}
diff --git a/components/ParticipantBackgroundForm.tsx b/components/ParticipantBackgroundForm.tsx
index 7e4f1c9..2c5f547 100644
--- a/components/ParticipantBackgroundForm.tsx
+++ b/components/ParticipantBackgroundForm.tsx
@@ -49,13 +49,15 @@ export function ParticipantBackgroundForm() {
}, []);
useEffect(() => {
- if (!session) {
- return;
- }
-
- const updatedSession = { ...session, participantProfile: profile };
- saveStoredSession(updatedSession);
- setSession(updatedSession);
+ setSession((currentSession) => {
+ if (!currentSession) {
+ return currentSession;
+ }
+
+ const updatedSession = { ...currentSession, participantProfile: profile };
+ saveStoredSession(updatedSession);
+ return updatedSession;
+ });
}, [profile]);
const isComplete =
diff --git a/docs/MANUAL_QA_CHECKLIST.md b/docs/MANUAL_QA_CHECKLIST.md
new file mode 100644
index 0000000..ae36b2c
--- /dev/null
+++ b/docs/MANUAL_QA_CHECKLIST.md
@@ -0,0 +1,89 @@
+# Manual QA Checklist
+
+Use a fresh browser profile or an incognito window unless a step explicitly asks you to keep existing localStorage. Open DevTools > Application > Local Storage for the app origin when verifying saved session fields.
+
+## 1. Fresh participant flow
+
+1. Visit `/`.
+ - Expected: Introduction loads without errors and offers a consent/start action.
+2. Continue to `/consent` and provide consent.
+ - Expected: Progress advances to background; localStorage contains a `hidden-cost-game-session` object with `currentStage` set to `background`.
+3. Complete `/background` with valid answers.
+ - Expected: App navigates to `/game`; `participantProfile` is saved.
+4. Complete every round on `/game`.
+ - Expected: App navigates to `/visible-results`; `game.completedAt` is saved and `game.rounds` contains all rounds.
+5. Continue to `/pre-reveal-survey`; answer all items with 10-500 characters in the open response.
+ - Expected: App either navigates directly to `/hidden-rule-reveal` or, for delayed reveal assignment, to `/pre-reveal-reflection`.
+6. If `/pre-reveal-reflection` appears, complete it.
+ - Expected: App navigates to `/hidden-rule-reveal`; `preRevealCommitment` is saved.
+7. Continue from `/hidden-rule-reveal` to `/post-reveal-survey`.
+ - Expected: `revealViewedAt` is saved; hidden rule content displays the assigned profile.
+8. Complete `/post-reveal-survey`.
+ - Expected: App navigates to `/individual-results`; `postRevealSurveyCompletedAt` is saved.
+9. On `/individual-results`, review the export panel.
+ - Expected: Completeness banner says export is complete; JSON includes `computedMetrics`, `gameRounds`, `preRevealSurvey`, and `postRevealSurvey`.
+10. Visit `/export/submission`.
+ - Expected: Submission export page loads, shows the same completed export, and does not 404.
+
+## 2. Back navigation after reveal revision assignment
+
+Run this twice if possible to observe both randomized revision conditions.
+
+1. Complete the flow through `/hidden-rule-reveal`, then navigate back to `/pre-reveal-survey` by browser Back or direct URL.
+ - Expected: No earlier page warned the participant that this revision assignment could happen.
+2. Inspect localStorage.
+ - Expected: `revisionAccess.assignedAfterReveal` is `true`, `revisionAccess.trigger` is `post-reveal-back-navigation`, and `preRevealRevision.attempted` is `true`.
+3. If `revisionAccess.condition` is `revision-unlocked`, revise at least one answer and submit.
+ - Expected: Inputs are editable; `preRevealSurveyOriginal` preserves the initial answers; `preRevealSurveyRevisedAfterReveal` stores the revised answers; `preRevealRevision.allowed` and `used` are `true`; `revisedAt` is set.
+4. If `revisionAccess.condition` is `revision-locked`, try to edit.
+ - Expected: Form fields are not shown for editing; page states responses are already recorded; `preRevealRevision.allowed` and `used` are `false`; `blockedAt` is set; original `preRevealSurvey` is unchanged.
+5. Continue to `/post-reveal-survey`.
+ - Expected: Flow resumes without data loss.
+
+## 3. Replay game
+
+1. From `/individual-results`, choose the optional second playthrough.
+ - Expected: `/replay-game` opens with text saying no additional survey questions will be asked.
+2. Complete the replay.
+ - Expected: App returns to `/individual-results`; `replayGame.completedAt` is saved separately from `game.completedAt`.
+3. Compare `game.rounds` and `replayGame.rounds` in localStorage.
+ - Expected: Original game rounds remain unchanged; replay choices only appear under `replayGame`.
+4. Review export JSON before any server submission.
+ - Expected: JSON includes `replayGame`; computed metrics include replay behavior fields such as `replayCompleted` and behavior-change metrics.
+
+## 4. Old or stale localStorage
+
+1. In DevTools, replace the session value with an object missing newer optional fields, for example only `sessionId`, `createdAt`, `currentStage`, `background`, and `responses`.
+ - Expected: Reloading `/` or `/export/submission` does not crash; missing optional fields render as absent, blank, or null.
+2. Replace `preRevealSurvey` or `postRevealSurvey` with an object missing some fields.
+ - Expected: Survey pages load with safe blank/default values instead of throwing an error.
+3. Use **Reset study session** from the export panel.
+ - Expected: localStorage key is removed and browser returns to `/` with a fresh session on next progress.
+
+## 5. Server submission
+
+These steps require `NEXT_PUBLIC_ENABLE_SERVER_SUBMISSION=true`, server submission enabled, and PostgreSQL configured.
+
+1. Try to submit before completing the full flow.
+ - Expected: Submit button is disabled and the UI says to complete the session before submitting.
+2. Complete the full flow and submit.
+ - Expected: `/api/submissions` returns success; UI displays a submission ID and timestamp; PostgreSQL has a new `researchSubmission` row.
+3. Configure an invalid Google Sheets webhook URL or force it to return a non-2xx response, then submit another completed session.
+ - Expected: PostgreSQL save still succeeds and the participant sees success; Google Sheets failure only logs a warning.
+4. Copy/download the complete export JSON and validate it against the Zod schema with `npm run validate:sample` as a baseline plus any local validation script you use for captured exports.
+ - Expected: Complete exports conform to the schema; incomplete local sessions are not accepted by `/api/submissions`.
+
+## 6. Admin dashboard and exports
+
+These steps require `ADMIN_EXPORT_TOKEN` and PostgreSQL data.
+
+1. Visit `/admin`, enter the admin token, and load the dashboard.
+ - Expected: Diagnostics, stats, and recent submissions load without crashing.
+2. Confirm stats cards render when optional fields such as replay or revision data are missing.
+ - Expected: Counts/averages use zero where appropriate; missing optional strings render blank/null rather than throwing.
+3. Click **Download JSON**.
+ - Expected: A JSON file downloads with recent submissions and payloads.
+4. Click **Download CSV**.
+ - Expected: CSV downloads; old payloads without optional replay/revision/cost-visibility fields leave those cells blank.
+5. Open the CSV.
+ - Expected: Rows include both old and new payload shapes without shifted columns or unescaped commas/newlines.
diff --git a/utils/session.ts b/utils/session.ts
index 610f662..c9dddee 100644
--- a/utils/session.ts
+++ b/utils/session.ts
@@ -61,21 +61,21 @@ function normalizeStoredSession(value: unknown, currentStage: StageId): Research
sessionId: typeof candidate.sessionId === "string" ? candidate.sessionId : `hcg-${crypto.randomUUID()}`,
createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : new Date().toISOString(),
currentStage: normalizeStageId(candidate.currentStage, currentStage),
- background: candidate.background ?? {},
- participantProfile: candidate.participantProfile,
- responses: candidate.responses ?? {},
- game: candidate.game,
- replayGame: candidate.replayGame,
- preRevealSurvey: candidate.preRevealSurvey,
- preRevealSurveyOriginal: candidate.preRevealSurveyOriginal ?? (candidate.preRevealSurveyCompletedAt ? candidate.preRevealSurvey : undefined),
- preRevealSurveyRevisedAfterReveal: candidate.preRevealSurveyRevisedAfterReveal,
- revisionAccess: candidate.revisionAccess,
- preRevealRevision: candidate.preRevealRevision,
- revealTimingCondition: candidate.revealTimingCondition,
- preRevealCommitment: candidate.preRevealCommitment,
- explanationFrameCondition: candidate.explanationFrameCondition,
- costVisibilityCondition: candidate.costVisibilityCondition,
- postRevealSurvey: candidate.postRevealSurvey,
+ background: isRecord(candidate.background) ? candidate.background : {},
+ participantProfile: normalizeParticipantProfile(candidate.participantProfile),
+ responses: isRecord(candidate.responses) ? candidate.responses : {},
+ game: isRecord(candidate.game) ? (candidate.game as ResearchSession["game"]) : undefined,
+ replayGame: isRecord(candidate.replayGame) ? (candidate.replayGame as ResearchSession["replayGame"]) : undefined,
+ preRevealSurvey: normalizePreRevealSurvey(candidate.preRevealSurvey),
+ preRevealSurveyOriginal: normalizePreRevealSurvey(candidate.preRevealSurveyOriginal) ?? (candidate.preRevealSurveyCompletedAt ? normalizePreRevealSurvey(candidate.preRevealSurvey) : undefined),
+ preRevealSurveyRevisedAfterReveal: normalizePreRevealSurvey(candidate.preRevealSurveyRevisedAfterReveal),
+ revisionAccess: isRecord(candidate.revisionAccess) ? (candidate.revisionAccess as ResearchSession["revisionAccess"]) : undefined,
+ preRevealRevision: isRecord(candidate.preRevealRevision) ? (candidate.preRevealRevision as ResearchSession["preRevealRevision"]) : undefined,
+ revealTimingCondition: isRecord(candidate.revealTimingCondition) ? (candidate.revealTimingCondition as ResearchSession["revealTimingCondition"]) : undefined,
+ preRevealCommitment: isRecord(candidate.preRevealCommitment) ? (candidate.preRevealCommitment as ResearchSession["preRevealCommitment"]) : undefined,
+ explanationFrameCondition: isRecord(candidate.explanationFrameCondition) ? (candidate.explanationFrameCondition as ResearchSession["explanationFrameCondition"]) : undefined,
+ costVisibilityCondition: isRecord(candidate.costVisibilityCondition) ? (candidate.costVisibilityCondition as ResearchSession["costVisibilityCondition"]) : undefined,
+ postRevealSurvey: normalizePostRevealSurvey(candidate.postRevealSurvey),
preRevealSurveyStartedAt: candidate.preRevealSurveyStartedAt,
preRevealSurveyCompletedAt: candidate.preRevealSurveyCompletedAt,
revealViewedAt: candidate.revealViewedAt,
@@ -88,6 +88,93 @@ function normalizeStoredSession(value: unknown, currentStage: StageId): Research
};
}
+function normalizeParticipantProfile(value: unknown): ResearchSession["participantProfile"] {
+ if (!isRecord(value)) {
+ return undefined;
+ }
+
+ return {
+ ageGroup: readString(value.ageGroup),
+ gender: readString(value.gender),
+ subjectiveEconomicStatus: readPreferNotOrNumber(value.subjectiveEconomicStatus, 1, 10),
+ medicalCostPressure: readString(value.medicalCostPressure),
+ healthcareCoverage: readString(value.healthcareCoverage),
+ specialOrganizationalCoverage: readString(value.specialOrganizationalCoverage),
+ inequalityOrientation: readLikertNullable(value.inequalityOrientation),
+ institutionalTrust: readLikertNullable(value.institutionalTrust),
+ priorExposureToUnequalSystems: readString(value.priorExposureToUnequalSystems),
+ policyPreferenceBaseline: readPreferNotOrNumber(value.policyPreferenceBaseline, 1, 7),
+ };
+}
+
+function normalizePreRevealSurvey(value: unknown): ResearchSession["preRevealSurvey"] {
+ if (!isRecord(value)) {
+ return undefined;
+ }
+
+ return {
+ primaryAttribution: readString(value.primaryAttribution),
+ individualResponsibility: readLikertDraft(value.individualResponsibility),
+ constraintSuspicion: readLikertDraft(value.constraintSuspicion),
+ protestLegitimacy: readLikertDraft(value.protestLegitimacy),
+ ruleCorrectionSupport: readLikertDraft(value.ruleCorrectionSupport),
+ redistributionSupport: readLikertDraft(value.redistributionSupport),
+ confidence: readLikertDraft(value.confidence),
+ informationSufficiency: readLikertDraft(value.informationSufficiency),
+ openExplanation: readString(value.openExplanation),
+ };
+}
+
+function normalizePostRevealSurvey(value: unknown): ResearchSession["postRevealSurvey"] {
+ if (!isRecord(value)) {
+ return undefined;
+ }
+
+ return {
+ rememberedPrimaryAttribution: readString(value.rememberedPrimaryAttribution),
+ rememberedIndividualResponsibility: readLikertDraft(value.rememberedIndividualResponsibility),
+ rememberedConstraintSuspicion: readLikertDraft(value.rememberedConstraintSuspicion),
+ rememberedConfidence: readLikertDraft(value.rememberedConfidence),
+ revisedPrimaryAttribution: readString(value.revisedPrimaryAttribution),
+ revisedIndividualResponsibility: readLikertDraft(value.revisedIndividualResponsibility),
+ perceivedStructuralImpact: readLikertDraft(value.perceivedStructuralImpact),
+ postProtestLegitimacy: readLikertDraft(value.postProtestLegitimacy),
+ postRuleCorrectionSupport: readLikertDraft(value.postRuleCorrectionSupport),
+ postRedistributionSupport: readLikertDraft(value.postRedistributionSupport),
+ initialJudgmentAccuracy: readLikertDraft(value.initialJudgmentAccuracy),
+ perspectiveChange: readLikertDraft(value.perspectiveChange),
+ openRevision: readString(value.openRevision),
+ };
+}
+
+function readString(value: unknown): string {
+ return typeof value === "string" ? value : "";
+}
+
+function readLikertDraft(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
+}
+
+function readLikertNullable(value: unknown): number | null {
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
+}
+
+function readPreferNotOrNumber(value: unknown, min: number, max: number): number | "Prefer not to answer" | null {
+ if (value === "Prefer not to answer") {
+ return value;
+ }
+
+ if (typeof value === "number" && Number.isInteger(value) && value >= min && value <= max) {
+ return value;
+ }
+
+ return null;
+}
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
function normalizeStageId(value: unknown, fallback: StageId): StageId {
if (value === "results") {
return fallback;