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;