diff --git a/.claude/worktrees/admiring-leavitt-72e4d2 b/.claude/worktrees/admiring-leavitt-72e4d2
deleted file mode 160000
index c54ef231c..000000000
--- a/.claude/worktrees/admiring-leavitt-72e4d2
+++ /dev/null
@@ -1 +0,0 @@
-Subproject commit c54ef231c43fa30a03a38d23400176260edfbb36
diff --git a/.gitignore b/.gitignore
index d14b9d924..97f543407 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,7 @@
# IDE specific
.cursor/plans*
+.claude/worktrees/
# dependencies
node_modules
diff --git a/packages/docs/audits/checklist-data-access-migration.md b/packages/docs/audits/checklist-data-access-migration.md
deleted file mode 100644
index 0e9ad5c70..000000000
--- a/packages/docs/audits/checklist-data-access-migration.md
+++ /dev/null
@@ -1,77 +0,0 @@
-# Checklist data access: migration state after `useChecklistAnswers`
-
-## Context
-
-`ChecklistYjsWrapper` now reads answers via `useChecklistAnswers` (issue #480). The broader refactor (issues #481–#483) is not yet done. This doc enumerates the remaining callers of `getChecklistData`, explains what each one uses the data for, and whether it should eventually migrate to the hook or stay imperative.
-
-## Status: is there old code to delete?
-
-No. The only thing #480 removed from `ChecklistYjsWrapper` is the `getChecklistData` destructuring. The function itself is still exported from `createChecklistOperations` and still has four callers. No `refreshKey` remains in the tree (already removed by an earlier pass). Nothing is orphaned.
-
-## Callers of `getChecklistData`
-
-### 1. `project.checklist.getData()` — public typed API
-
-- File: `packages/web/src/project/actions.ts:82-86`
-- Shape: one-shot read, imperative, called outside render.
-- Currently consumed by `OverviewTab` (see #4 below) — no other direct callers.
-- **Keep.** This is the imperative facade. It is the correct home for "grab the answers right now" calls that don't participate in a component's subscription model. Issue #483 will refine its types; the call shape is fine.
-
-### 2. `calculateInterRaterReliability` (utility)
-
-- File: `packages/web/src/lib/inter-rater-reliability.ts:34-39, 84-85`
-- Shape: pure function, takes `getChecklistData` as a parameter, loops over every dual-reviewer AMSTAR2 study in a project and reads both reviewers' completed answers.
-- **Keep imperative.** A per-checklist subscription hook doesn't fit: this computes across N studies × 2 reviewers in one pass. A hook would require either N×2 hook calls (illegal dynamically) or a second, project-scoped subscription that re-derives the whole table on any change.
-- **Optional future sharpening:** if we want this to be live (metrics update as reviewers finalize), expose a `useProjectChecklistVersion(projectId)` hook that bumps on any `reviews` change, add it as a `useMemo` dep, and keep the imperative read inside the memo. That's the minimal cost for reactivity without breaking the computation shape.
-
-### 3. `OverviewTab`
-
-- File: `packages/web/src/components/project/overview-tab/OverviewTab.tsx:171-175`
-- Shape: wraps `project.checklist.getData` in a lambda and passes it to `calculateInterRaterReliability`.
-- **Keep.** Follows directly from #2.
-
-### 4. `PreviousReviewersView`
-
-- File: `packages/web/src/components/project/completed-tab/PreviousReviewersView.tsx:39, 66, 68`
-- Shape: reads two completed-reviewer checklists in `useEffect` on dialog open, stashes them in local state, renders read-only.
-- Target checklists have status `REVIEWER_COMPLETED` or later — editing is locked by `isEditable()`.
-- **Migrate to `useChecklistAnswers` eventually, low priority.** No correctness bug today (the checklists are locked, so the one-shot read is accurate). Migration value is consistency and killing one more `useEffect` that syncs Y.Doc → local state.
-- Blocker: the component reads _two_ checklists. Calling the hook twice conditionally would violate Rules of Hooks. Solution: either (a) always call it twice with `null`-safe IDs, or (b) pass `checklistId` as an argument and split into `` children so each panel calls the hook once. (b) is cleaner.
-
-### 5. `ReconciliationWrapper`
-
-- File: `packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx:69, 200-217, 219-237, 380-391`
-- Shape: reads three checklists (checklist1, checklist2, reconciled), builds UI payloads including reviewer names and createdAt, feeds into the reconciliation view.
-- Reactivity **matters here**: during reconciliation both source checklists are typically `REVIEWER_COMPLETED` (locked) but the reconciled checklist is actively edited. If another tab or collaborator writes to the reconciled Y.Doc, this view needs to reflect it.
-- Current invalidation: `getChecklistData` is pulled from destructured `ops.checklist` and used as a `useMemo` dep. It is a stable reference — so the memos only re-run when `currentStudy` flips in Zustand, which is the same fragile path #480 just moved away from.
-- **Migrate.** Replace the three `useMemo(() => getChecklistData(...))` blocks with three `useChecklistAnswers` calls and spread the returned answers into the existing UI-shape objects. Same transformation as `ChecklistYjsWrapper`.
-- Scope: ~40 lines touched in one component. Low risk. Good candidate to pair with the #481 local/collab unify work or land separately right after #480.
-
-## Summary table
-
-| Caller | Keep `getChecklistData`? | Reason |
-| -------------------------------- | ------------------------- | ------------------------------------------------------- |
-| `project.checklist.getData()` | Yes | Imperative facade for one-shot reads |
-| `calculateInterRaterReliability` | Yes | Cross-checklist aggregation; hook doesn't fit |
-| `OverviewTab` | Yes | Thin wrapper over the above |
-| `PreviousReviewersView` | Migrate (low priority) | Locked data, but consistency + removes effect |
-| `ReconciliationWrapper` | Migrate (medium priority) | Reconciled checklist is live-edited; reactivity matters |
-
-`getChecklistData` stays in the surface area indefinitely — the imperative path is legitimate. The hook is the right default for reactive components, not a total replacement.
-
-## Local-practice migration (issue #481)
-
-**Not yet written.** Local-practice checklists live in the Dexie `localChecklists` table (`packages/web/src/stores/localChecklistsStore.ts`) as flat JSON blobs with a `data: unknown` field containing the full checklist template shape. They do not use Y.Doc, y-dexie, or the sync layer.
-
-When issue #481 lands, the migration step is:
-
-1. On first app load after upgrade, detect entries in `localChecklists`.
-2. For each entry, create a local Y.Doc (persisted via y-dexie, no WebSocket provider) under a stable ID.
-3. Convert the stored `data` using the appropriate `handler.createAnswersYMap` + write flat fields into the Y.Doc.
-4. Mark the migration done via a one-shot flag (e.g., `localStorage['local-checklists-migrated-v1'] = '1'`) so we don't re-run.
-5. Leave the old `localChecklists` and `localChecklistPdfs` tables in place for one release as rollback insurance.
-6. Next release: drop the tables, delete `localChecklistsStore.ts`, delete `LocalChecklistView`, delete `CreateLocalChecklist`, re-point `/checklist/$checklistId` and dashboard sidebar entries to the unified view.
-
-Migration correctness should be verified by a round-trip test: every `data` shape that has appeared in the wild, run through the migration, then `handler.serializeAnswers(migrated)` equals the original `data` modulo Y.Text vs. string for note fields.
-
-Until #481 ships, no user-facing local-checklist data is at risk: the old store and old view keep working unchanged. `useChecklistAnswers` is only exercised by collaborative (project-scoped) checklists.
diff --git a/packages/docs/audits/hono-to-tanstack-migration.md b/packages/docs/audits/hono-to-tanstack-migration.md
deleted file mode 100644
index f77887128..000000000
--- a/packages/docs/audits/hono-to-tanstack-migration.md
+++ /dev/null
@@ -1,84 +0,0 @@
-# Hono → TanStack Start migration
-
-The Hono app in `packages/workers` was migrated to TanStack Start file routes in `packages/web` over 27 numbered passes; tracked in [#484](https://github.com/InfinityBowman/corates/issues/484), [#485](https://github.com/InfinityBowman/corates/issues/485), [#486](https://github.com/InfinityBowman/corates/issues/486). Per-pass narrative lives in `git log`. Post-migration follow-ups (what didn't survive): [post-migration-regressions.md](./post-migration-regressions.md).
-
-## Current architecture
-
-- **API routes** live in `packages/web/src/routes/api/**` as TanStack file routes.
-- **`packages/workers`** is now a library workspace (Durable Objects, commands, lib helpers, auth/email, queue consumer) consumed via subpath exports declared in `packages/workers/package.json`. It no longer deploys.
-- **`packages/web/src/server.ts`** is the worker entry. It (1) routes WebSocket-DO paths (`/api/project-doc/*`, `/api/sessions/*`) to DO stubs _before_ TanStack — TanStack Start can't pass WS upgrades through, (2) calls `createStartHandler(defaultStreamHandler)` for everything else, threading `cloudflareCtx` through `context` so handlers can `waitUntil`, (3) wraps the whole thing in `Sentry.withSentry`, (4) delegates queue consumption to `handleEmailQueue` from `@corates/workers/queue`.
-
-## Adding a route
-
-```ts
-// packages/web/src/routes/api/orgs/$orgId/some-path.ts
-import { createFileRoute } from '@tanstack/react-router';
-import { env } from 'cloudflare:workers';
-import { requireOrgMembership } from '@/server/guards/requireOrgMembership';
-
-type HandlerArgs = { request: Request; params: { orgId: string } };
-
-export const handleGet = async ({ request, params }: HandlerArgs) => {
- const guard = await requireOrgMembership(request, env, params.orgId);
- if (!guard.ok) return guard.response;
- // ... business logic, return Response.json(...)
-};
-
-export const Route = createFileRoute('/api/orgs/$orgId/some-path')({
- server: { handlers: { GET: handleGet } },
-});
-```
-
-- Export `handleGet`/`handlePost`/`handlePut`/`handleDelete` named so tests can import directly without going through the router.
-- TanStack regenerates `routeTree.gen.ts` on file changes (gitignored). Sometimes needs a `pnpm --filter web build` to pick up new files.
-- For fire-and-forget work (notifications, async logging, ledger updates), extend args with `context?: { cloudflareCtx?: ExecutionContext }` and check `context?.cloudflareCtx?.waitUntil`. Tests omit it; production has it. See `routes/api/admin/orgs/$orgId/subscriptions.ts`'s `dispatchSubscriptionNotify` helper.
-
-## Guards (`packages/web/src/server/guards/`)
-
-Discriminated-union return: `{ ok: true; context } | { ok: false; response }`.
-
-- `requireOrgMembership(request, env, orgId, minRole?)`
-- `requireProjectAccess(request, env, orgId, projectId, minRole?)`
-- `requireOrgWriteAccess(method, env, orgId)` — early-returns for read methods
-- `requireEntitlement(env, orgId, key)`
-- `requireQuota(env, orgId, getUsage, requested?)`
-- `requireAdmin(request, env)` — bundles CSRF (mutations only) → session → admin role. CSRF is bundled here because the Hono mount applied `requireTrustedOrigin` umbrella-style; bundling preserves that without per-route boilerplate.
-- `requireTrustedOrigin(request, { isProduction })` — standalone CSRF; only `stop-impersonation.ts` uses it directly (impersonated user lacks admin role).
-
-Call order for write routes: `requireOrgMembership` → `requireOrgWriteAccess` → `requireProjectAccess`. For project sub-routes, put `requireOrgMembership` first so a stranger gets `not_org_member` rather than `project_access_denied`.
-
-## Tests
-
-Tests call handlers directly — no routing, no `app.fetch`. Use `env` from `cloudflare:test` for real D1/R2/DOs.
-
-```ts
-import { env } from 'cloudflare:test';
-import { handleGet } from '../some-route';
-
-vi.mock('@corates/workers/auth', () => ({
- getSession: async () => ({
- user: { id: 'u1', email: 'u1@example.com', name: 'U1' },
- session: { id: 'sess', userId: 'u1' },
- }),
-}));
-```
-
-Mock `@corates/workers/auth.getSession` to impersonate users. Mock `@corates/workers/billing-resolver.resolveOrgAccess` for quota/entitlement paths. Postmark mock only needed if the route sends email.
-
-**Mutating admin tests must include a trusted Origin header** because `requireAdmin` runs CSRF first. Use `origin: 'http://localhost:3010'` in request init or via the `jsonReq`/`deleteReq` helpers in those test files.
-
-## Gotchas worth knowing
-
-- **Subpath exports + type firewalls.** Web doesn't pull in `@cloudflare/workers-types`. Any subpath export whose public surface includes `DurableObject` or `MessageBatch` types needs a hand-written `.d.ts` firewall stub — see `packages/workers/{durable-objects,queue,auth,types}.d.ts`. Without it web's tsc complains about missing `ctx`/`Message`.
-- **R2 boundary types.** When web transitively compiles workers code, R2 types can come back as `unknown`. Annotate explicitly at the boundary — see `packages/workers/src/commands/lib/doSync.ts`.
-- **TanStack file-route specificity wins over splats.** Routes sort Index → Static (most-specific first) → Dynamic (longest first) → Splat ([Route Matching docs](https://tanstack.com/router/latest/docs/routing/route-matching)). So `routes/api/auth/stripe/webhook.ts` reliably beats `routes/api/auth/$.ts` regardless of definition order.
-- **Catch-all handlers must be exported when tested.** TanStack types `server.handlers` as a function (not a record), so `Route.options.server!.handlers!.POST` fails to typecheck. Export the handler function (`export const handle = ...`) and import it directly.
-- **`vi.mock` factories can't reference module-scoped variables.** Hoisting throws `Cannot access 'mockFn' before initialization`. Use `const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }))`.
-- **Self-action validation uses `details.constraint`, not `details.reason`.** `createValidationError` puts the value in `details.constraint`. Trip wires: self-ban, self-impersonate, self-delete in admin user routes.
-- **`createInvitation` always sets `grantOrgMembership: true`.** The Hono schema accepted it as optional, but the command hard-codes true. Tests rely on this.
-- **`DEV_MODE` in tests.** Dev routes need `"DEV_MODE": true` in `packages/web/wrangler.test.jsonc`. Don't try `vi.mock('cloudflare:workers')` — it hangs the pool.
-- **Client error throws are plain objects.** Callers `throw data` directly where `data` already has `code` + `statusCode`. The Hono-RPC era `DetailedError instanceof` checks are gone — just shape-check.
-
-## Future: fold workers into web
-
-The workers package is now a library workspace whose only consumer is web. Folding its source into `packages/web/src/server/` would eliminate the subpath-export indirection. Mechanical refactor; touches every `@corates/workers/*` import site. Do it when the indirection becomes annoying.
diff --git a/packages/docs/audits/post-migration-regressions.md b/packages/docs/audits/post-migration-regressions.md
deleted file mode 100644
index c83b26f9b..000000000
--- a/packages/docs/audits/post-migration-regressions.md
+++ /dev/null
@@ -1,158 +0,0 @@
-# Post-migration regressions — what the Hono retirement actually lost
-
-The Hono → TanStack Start migration ([#484](https://github.com/InfinityBowman/corates/issues/484), [#485](https://github.com/InfinityBowman/corates/issues/485), [#486](https://github.com/InfinityBowman/corates/issues/486)) ported every route. After verifying claim-by-claim against current code, the genuine regressions are smaller than they first appeared, and the merge-prep pass closed the operational ones.
-
-Severity legend:
-
-- **block** — fix before merging the `retire-hono-app` branch
-- **soon** — degraded contract or operational visibility; fix shortly after merge
-- **followup** — quality-of-life; no immediate impact
-
----
-
-## block — none
-
-Initial audit flagged security headers and CSRF as blockers. Both turned out to be already-handled or already-fixed:
-
-- **Security headers (HTML + static assets):** `packages/web/src/routes/__root.tsx` sets `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Permissions-Policy`, and CSP on every TanStack-served HTML response. `packages/web/public/_headers` sets the same on every static response. The Hono middleware was redundant for the SPA's HTML.
-- **Admin CSRF:** restored Pass 26 by folding `requireTrustedOrigin` into `requireAdmin`.
-
-Nothing is a hard merge blocker.
-
----
-
-## Resolved during merge prep
-
-Three small fixes landed before merging the `retire-hono-app` branch:
-
-### HSTS header — restored
-
-Added `Strict-Transport-Security: max-age=15552000; includeSubDomains` to both `__root.tsx` (TanStack HTML responses) and `public/_headers` (static asset responses). Mirrors the original Hono header on every HTTPS response.
-
-### `/api/$.ts` JSON 404 — restored
-
-`routes/api/$.ts` returns the `SYSTEM_ROUTE_NOT_FOUND` JSON (with the requested path in `details.path`) for every method. TanStack's specificity sort guarantees it only fires for paths no concrete API route claims. Two tests in `routes/api/__tests__/not-found.server.test.ts`.
-
-### Health deep-check endpoint — restored
-
-`routes/health.ts` re-exposes the original Hono `/health` shape: D1 `SELECT 1`, R2 `list({ limit: 1 })`, DO bindings present. Returns 503 with per-service status when anything degrades. The plain `OK` liveness probe stays at `/healthz`.
-
----
-
-## soon — operational gaps
-
-### CORS — gone, but not currently breaking anything
-
-**What we had.** `packages/workers/src/middleware/cors.ts` wrapped Hono's `cors()` with allow-list, methods, headers, credentials, OPTIONS preflight.
-
-**What's now there.** Nothing.
-
-**Why it's not breaking.** Browsers don't enforce CORS on same-origin requests, including non-simple ones. Production is `corates.org` SPA → `corates.org/api` (same-origin); local dev is `localhost:3010` → `localhost:3010/api` (same-origin); programmatic clients ignore CORS.
-
-**When it'd start mattering.** Different-origin SPA, dev pointing at prod, third-party embeds, public API clients in browsers.
-
-**Fix when needed.** Wrapper in `packages/web/src/server.ts`, before the WS dispatch and TanStack handoff. Reuse `isOriginAllowed`/`STATIC_ORIGINS` from `@corates/workers/config/origins`.
-
----
-
-## followup — quality and parity
-
-### Centralized error handler — degraded but not broken
-
-**What we had.** `packages/workers/src/middleware/errorHandler.ts` (`base.onError`) caught every uncaught error in route handlers and converted:
-
-- Zod errors → `VALIDATION_ERROR` JSON with field paths
-- D1 errors (`D1_*` prefix) → `DB_ERROR` with operation context
-- `UNIQUE constraint failed` → 409 with friendly message
-- `FOREIGN KEY constraint failed` → 400
-- Other errors → `INTERNAL_ERROR` (stack stripped in production)
-
-**What's now there.** Each TanStack handler has its own try/catch — most return `SYSTEM_ERRORS.DB_ERROR` for any thrown error. Constraint-violation classification (UNIQUE → 409, FK → 400) is gone. Uncaught exceptions inside handler logic propagate as TanStack error responses rather than `INTERNAL_ERROR` JSON.
-
-**Fix.** Either (a) a small `wrapHandler(fn)` helper that does the classification once, applied to every export, or (b) extend per-route try/catches to detect the same string patterns. (a) is cleaner.
-
-### Validation error quality — flat in 4 routes
-
-**Surveyed all migrated routes.** 34 routes use `createValidationError` properly (field paths, error codes). 4 admin routes catch Zod errors and return a flatter `INVALID_INPUT { field: 'body', value: err.message }` shape:
-
-- `routes/api/admin/orgs/$orgId/grants.ts`
-- `routes/api/admin/orgs/$orgId/grants/$grantId.ts`
-- `routes/api/admin/orgs/$orgId/subscriptions.ts`
-- `routes/api/admin/orgs/$orgId/subscriptions/$subscriptionId.ts`
-
-**Impact.** Admin UI clients of those 4 routes can't highlight specific failing fields from a 400 response. Only matters if the admin grants/subscriptions UI shows form-field errors.
-
-**Fix.** Replace `Schema.parse(raw)` with `Schema.safeParse(raw)` and pull the first issue's path/message into a proper `VALIDATION_ERROR`.
-
-### Request ID propagation — partial
-
-**What we had.** `packages/workers/src/lib/observability/logger.ts` (deleted Pass 25) generated or honoured `x-request-id` and set it on every response; structured logs included `requestId`, `cfRay`, `route`, `method`.
-
-**What's now there.** Only the Stripe webhook still threads `requestId` (Pass 22 ported it inline). Other routes log without correlation IDs and don't set the response header.
-
-**Impact.** Harder to trace a single request through logs. CF's `cf-ray` header still exists for cross-system correlation.
-
-**Fix.** Add request-ID generation in `packages/web/src/server.ts` (single point), expose it via the request context for handlers, and set it on the outgoing response.
-
-### X-Content-Type-Options on API responses — gone
-
-**Subtle.** `_headers` and `__root.tsx` set this header on static assets and SPA HTML. API JSON responses don't have it. Hono set it on every response.
-
-**Impact.** Tiny. The header prevents browsers from MIME-sniffing a response with the wrong `Content-Type`. JSON responses with `Content-Type: application/json` aren't at risk because that's not an HTML-sniff target.
-
-**Fix.** Optionally set it in a thin response wrapper in `server.ts`. Low value.
-
-### Auth rate limit `skipFailedRequests` — gone
-
-**What we had.** Hono's `authRateLimit` set `skipFailedRequests: true` — failed sign-ins/sign-ups didn't count toward the 20-per-15-min budget.
-
-**What's now there.** `packages/web/src/server/rateLimit.ts`'s `checkRateLimit` API doesn't support skipping. `AUTH_RATE_LIMIT` (used in `routes/api/auth/$.ts` catch-all) counts every request, success or fail.
-
-**Impact.** Slightly easier to lock out a real user typing their password wrong. With a 20/15min budget, a user typo-ing 5 times still has room.
-
-**Fix.** Add an optional refund step in `checkRateLimit` (or a `refundRateLimit` helper) and call it in the catch-all when better-auth returns a 4xx representing a failed credential attempt.
-
-### OpenAPI / `/docs` page — gone
-
-**What we had.** `@hono/zod-openapi` routes generated an OpenAPI spec; the dev-only `/docs` page rendered it.
-
-**What's now there.** No spec, no docs page. Removed deliberately in Pass 25.
-
-**Impact.** Lost a dev convenience. Production never served `/docs` (404 outside dev mode), so no user impact.
-
-**Fix.** Optional — regenerate from TanStack handlers if anyone misses it. Low value unless something automated consumed the spec.
-
----
-
-## Out of scope — already removed deliberately
-
-- `/api/$.ts` Hono catch-all forwarder (Pass 25; replaced with the JSON 404 catch-all above)
-- 410 legacy endpoints (`/api/orgs/$orgId/project-doc/$projectId`, `/api/project/$projectId`)
-- Hono RPC client (`packages/web/src/lib/rpc.ts`) and `DetailedError` instance checks (Pass 25)
-
----
-
-## Verified preserved (not regressions)
-
-Audited and confirmed in current code:
-
-- Security headers on **HTML responses** — `__root.tsx`'s `headers()` callback sets HSTS, X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, CSP
-- Security headers on **static assets** — `public/_headers` sets the same six
-- Admin CSRF — `requireAdmin` runs `requireTrustedOrigin` first (Pass 26)
-- Liveness probe — `/healthz` returns "OK"
-- Health deep-check — `/health` returns JSON with D1/R2/DO status (restored during merge prep)
-- API JSON 404 — `routes/api/$.ts` returns `SYSTEM_ROUTE_NOT_FOUND` for unmatched paths (restored during merge prep)
-- Sentry server-side error monitoring — `Sentry.withSentry` wraps the worker default export
-- Sentry client-side — `packages/web/src/config/sentry.ts` initialises `@sentry/react`
-- All API routes from the original Hono app (auth, billing, admin, orgs, projects, users, PDF, dev, Google Drive, invitations) are migrated and tested
-
----
-
-## Suggested order for an "ops" follow-up branch
-
-1. Centralized error wrapper (`wrapHandler` helper) — retrofit one route at a time.
-2. Validation helper for the 4 lazy admin routes.
-3. Request-ID propagation in `server.ts` + handler context.
-4. Auth rate-limit refund.
-5. CORS — only when we move off pure same-origin.
-6. OpenAPI — only if something automated wants the spec.
diff --git a/packages/shared/src/checklists/amstar2/compare.ts b/packages/shared/src/checklists/amstar2/compare.ts
index 777738b41..35f3a7ea6 100644
--- a/packages/shared/src/checklists/amstar2/compare.ts
+++ b/packages/shared/src/checklists/amstar2/compare.ts
@@ -8,7 +8,7 @@ import type { AMSTAR2Checklist, AMSTAR2Question } from '../types.js';
import { AMSTAR_CHECKLIST, AMSTAR2_QUESTION_KEYS } from './schema.js';
import { getFinalAnswer, answersMatch } from './answers.js';
-interface QuestionComparison {
+export interface QuestionComparison {
isAgreement: boolean;
finalMatch: boolean;
criticalMatch: boolean;
@@ -25,7 +25,7 @@ interface QuestionComparison {
};
}
-interface MultiPartComparison {
+export interface MultiPartComparison {
isAgreement: boolean;
isMultiPart: true;
parts: Array<{
@@ -41,7 +41,7 @@ interface MultiPartComparison {
reviewer2Answer: AMSTAR2Question[];
}
-interface ComparisonResult {
+export interface ComparisonResult {
agreements: Array<{ key: string } & (QuestionComparison | MultiPartComparison)>;
disagreements: Array<{ key: string } & (QuestionComparison | MultiPartComparison)>;
stats: {
diff --git a/packages/web/src/components/checklist/ROBINSIChecklist/checklist-compare.ts b/packages/web/src/components/checklist/ROBINSIChecklist/checklist-compare.ts
index a3f1101ea..12b2229d3 100644
--- a/packages/web/src/components/checklist/ROBINSIChecklist/checklist-compare.ts
+++ b/packages/web/src/components/checklist/ROBINSIChecklist/checklist-compare.ts
@@ -87,7 +87,7 @@ interface ComparisonStats {
agreementRate?: number;
}
-interface ComparisonResult {
+export interface ComparisonResult {
sectionB: SectionBComparison;
domains: Record;
overall: OverallComparison;
diff --git a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/adapter.tsx b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/adapter.tsx
index d8a6c5ff1..13fdfc2df 100644
--- a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/adapter.tsx
+++ b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/adapter.tsx
@@ -7,11 +7,19 @@
import type {
ReconciliationAdapter,
- ReconciliationNavItem,
EngineContext,
NavbarContext,
SummaryContext,
} from '../engine/types';
+
+export interface Amstar2NavItem {
+ key: string;
+ label: string;
+ section: string;
+ sectionKey: string;
+ type: 'single' | 'multiPart';
+ meta: { isMultiPart: boolean };
+}
import {
compareChecklists,
getReconciliationSummary,
@@ -19,6 +27,7 @@ import {
getDataKeysForQuestion,
isMultiPartQuestion,
} from '@/components/checklist/AMSTAR2Checklist/checklist-compare.js';
+import type { ComparisonResult } from '@corates/shared/checklists/amstar2';
import { createChecklist } from '@/components/checklist/AMSTAR2Checklist/checklist.js';
import { hasQuestionAnswer } from './navbar-utils.js';
import { ReconciliationQuestionPage } from './ReconciliationQuestionPage';
@@ -31,7 +40,7 @@ import { SummaryView } from './SummaryView';
const questionKeys: string[] = getQuestionKeys();
-function buildNavItems(): ReconciliationNavItem[] {
+function buildNavItems(): Amstar2NavItem[] {
return questionKeys.map(key => ({
key,
label: key.replace('q', ''),
@@ -49,9 +58,8 @@ const AMSTAR2_NAV_ITEMS = buildNavItems();
// Data derivation
// ---------------------------------------------------------------------------
-function deriveFinalAnswers(reconciledChecklist: unknown): Record {
+function deriveFinalAnswers(reconciledChecklist: any): Record {
if (!reconciledChecklist) return {};
- const rc = reconciledChecklist as Record;
const answers: Record = {};
for (const key of questionKeys) {
@@ -60,14 +68,14 @@ function deriveFinalAnswers(reconciledChecklist: unknown): Record {
const parts: Record = {};
let hasAnyPart = false;
for (const dk of dataKeys) {
- if (rc[dk]) {
- parts[dk] = rc[dk];
+ if (reconciledChecklist[dk]) {
+ parts[dk] = reconciledChecklist[dk];
hasAnyPart = true;
}
}
if (hasAnyPart) answers[key] = parts;
} else {
- if (rc[key]) answers[key] = rc[key];
+ if (reconciledChecklist[key]) answers[key] = reconciledChecklist[key];
}
}
@@ -78,16 +86,16 @@ function deriveFinalAnswers(reconciledChecklist: unknown): Record {
// Comparison
// ---------------------------------------------------------------------------
-function compare(checklist1: unknown, checklist2: unknown): unknown {
+function compare(checklist1: any, checklist2: any): ComparisonResult | null {
if (!checklist1 || !checklist2) return null;
- return compareChecklists(checklist1 as any, checklist2 as any);
+ return compareChecklists(checklist1, checklist2);
}
// Build a lookup map for isAgreement checks
-function buildComparisonByQuestion(comparison: any): Record {
+function buildComparisonByQuestion(comparison: ComparisonResult | null): Record {
if (!comparison) return {};
const map: Record = {};
- for (const item of [...(comparison.agreements || []), ...(comparison.disagreements || [])]) {
+ for (const item of [...comparison.agreements, ...comparison.disagreements]) {
map[item.key] = item;
}
return map;
@@ -97,11 +105,11 @@ function buildComparisonByQuestion(comparison: any): Record {
// Answer checking
// ---------------------------------------------------------------------------
-function hasAnswer(item: ReconciliationNavItem, finalAnswers: unknown): boolean {
- return hasQuestionAnswer(item.key, finalAnswers as Record);
+function hasAnswer(item: Amstar2NavItem, finalAnswers: Record): boolean {
+ return hasQuestionAnswer(item.key, finalAnswers);
}
-function isAgreement(item: ReconciliationNavItem, comparison: unknown): boolean {
+function isAgreement(item: Amstar2NavItem, comparison: ComparisonResult | null): boolean {
const map = buildComparisonByQuestion(comparison);
return map[item.key]?.isAgreement ?? true;
}
@@ -139,8 +147,8 @@ function writeAnswer(
}
function autoFillFromReviewer1(
- item: ReconciliationNavItem,
- checklist1: unknown,
+ item: Amstar2NavItem,
+ checklist1: any,
updateChecklistAnswer: (sectionKey: string, data: unknown) => void,
): void {
const defaultAnswer = getReviewerAnswers(checklist1, item.key);
@@ -176,10 +184,18 @@ function getReviewerNote(checklist: any, questionKey: string): string {
return '';
}
-function renderPage(context: EngineContext) {
- const { currentItem, checklist1, checklist2, finalAnswers, isAgreement, getTextRef } = context;
+function renderPage(
+ context: EngineContext, ComparisonResult | null, Amstar2NavItem>,
+) {
+ const {
+ currentItem,
+ checklist1,
+ checklist2,
+ finalAnswers: fa,
+ isAgreement,
+ getTextRef,
+ } = context;
const key = currentItem.key;
- const fa = finalAnswers as Record;
// Derive currentFinalAnswer from reconciledChecklist for current question
let currentFinalAnswer: any = null;
@@ -209,8 +225,8 @@ function renderPage(context: EngineContext) {
reviewer2Answers={getReviewerAnswers(checklist2, key)}
finalAnswers={currentFinalAnswer}
onFinalChange={handleFinalChange}
- reviewer1Name={context.reviewer1Name || (checklist1 as any)?.reviewerName || 'Reviewer 1'}
- reviewer2Name={context.reviewer2Name || (checklist2 as any)?.reviewerName || 'Reviewer 2'}
+ reviewer1Name={context.reviewer1Name || checklist1?.reviewerName || 'Reviewer 1'}
+ reviewer2Name={context.reviewer2Name || checklist2?.reviewerName || 'Reviewer 2'}
isAgreement={isAgreement}
isMultiPart={!!currentItem.meta?.isMultiPart}
reviewer1Note={getReviewerNote(checklist1, key)}
@@ -224,7 +240,9 @@ function renderPage(context: EngineContext) {
// Rendering: Navbar wrapper
// ---------------------------------------------------------------------------
-function Amstar2NavbarAdapter(navbarContext: NavbarContext) {
+function Amstar2NavbarAdapter(
+ navbarContext: NavbarContext, ComparisonResult | null, Amstar2NavItem>,
+) {
// Map NavbarContext to the shape the existing Navbar component expects
const comparisonByQuestion = buildComparisonByQuestion(navbarContext.comparison);
@@ -233,7 +251,7 @@ function Amstar2NavbarAdapter(navbarContext: NavbarContext) {
viewMode: navbarContext.viewMode,
currentPage: navbarContext.currentPage,
comparisonByQuestion,
- finalAnswers: navbarContext.finalAnswers as Record,
+ finalAnswers: navbarContext.finalAnswers,
setViewMode: navbarContext.setViewMode as (mode: string) => void,
goToQuestion: navbarContext.goToPage,
onReset: navbarContext.onReset,
@@ -246,8 +264,10 @@ function Amstar2NavbarAdapter(navbarContext: NavbarContext) {
// Rendering: Summary wrapper
// ---------------------------------------------------------------------------
-function Amstar2SummaryAdapter(summaryContext: SummaryContext) {
- const comparison = summaryContext.comparison as any;
+function Amstar2SummaryAdapter(
+ summaryContext: SummaryContext, ComparisonResult | null, Amstar2NavItem>,
+) {
+ const comparison = summaryContext.comparison;
const summary = comparison ? getReconciliationSummary(comparison) : null;
const comparisonByQuestion = buildComparisonByQuestion(comparison);
@@ -272,7 +292,12 @@ function Amstar2SummaryAdapter(summaryContext: SummaryContext) {
// Export adapter
// ---------------------------------------------------------------------------
-export const amstar2Adapter: ReconciliationAdapter = {
+export const amstar2Adapter: ReconciliationAdapter<
+ any,
+ Record,
+ ComparisonResult | null,
+ Amstar2NavItem
+> = {
checklistType: 'AMSTAR2',
title: 'Reconciliation',
pageCounterLabel: 'Question',
diff --git a/packages/web/src/components/project/reconcile-tab/engine/ReconciliationEngine.tsx b/packages/web/src/components/project/reconcile-tab/engine/ReconciliationEngine.tsx
index 74d50e67f..b83f334bd 100644
--- a/packages/web/src/components/project/reconcile-tab/engine/ReconciliationEngine.tsx
+++ b/packages/web/src/components/project/reconcile-tab/engine/ReconciliationEngine.tsx
@@ -28,16 +28,18 @@ import type { ReconciliationAdapter, ReconciliationEngineProps, EngineContext }
const EmbedPdfViewer = lazy(() => import('@/components/pdf/EmbedPdfViewer'));
// Adapter registry - adapters register themselves here via registerReconciliationAdapter
-const adapterRegistry = new Map();
+const adapterRegistry = new Map>();
export function registerReconciliationAdapter(
checklistType: string,
- adapter: ReconciliationAdapter,
+ adapter: ReconciliationAdapter,
) {
adapterRegistry.set(checklistType, adapter);
}
-function getReconciliationAdapter(checklistType: string): ReconciliationAdapter {
+function getReconciliationAdapter(
+ checklistType: string,
+): ReconciliationAdapter {
const adapter = adapterRegistry.get(checklistType);
if (!adapter) {
throw new Error(
@@ -117,7 +119,7 @@ export function ReconciliationEngine({
// Build EngineContext for renderPage
// -----------------------------------------------------------------------
- const engineContext: EngineContext | null = useMemo(() => {
+ const engineContext: EngineContext | null = useMemo(() => {
if (!engine.currentItem) return null;
return {
currentItem: engine.currentItem,
diff --git a/packages/web/src/components/project/reconcile-tab/engine/types.ts b/packages/web/src/components/project/reconcile-tab/engine/types.ts
index 3e8afc175..86e5d1070 100644
--- a/packages/web/src/components/project/reconcile-tab/engine/types.ts
+++ b/packages/web/src/components/project/reconcile-tab/engine/types.ts
@@ -58,15 +58,20 @@ export interface ReconciliationNavItem {
* The adapter uses these raw materials to slice reviewer data,
* build write callbacks, and compose existing page components.
*/
-export interface EngineContext {
- currentItem: ReconciliationNavItem;
- navItems: ReconciliationNavItem[];
- checklist1: unknown;
- checklist2: unknown;
+export interface EngineContext<
+ TChecklist,
+ TFinalAnswers,
+ TComparison,
+ TNavItem extends ReconciliationNavItem = ReconciliationNavItem,
+> {
+ currentItem: TNavItem;
+ navItems: TNavItem[];
+ checklist1: TChecklist;
+ checklist2: TChecklist;
/** Derived via adapter.deriveFinalAnswers */
- finalAnswers: unknown;
+ finalAnswers: TFinalAnswers;
/** From adapter.compare */
- comparison: unknown;
+ comparison: TComparison;
reviewer1Name: string;
reviewer2Name: string;
/** Pre-computed by engine via adapter.isAgreement for current item */
@@ -83,12 +88,16 @@ export interface EngineContext {
* Passed to adapter.NavbarComponent.
* The engine provides all navigation state; the navbar renders type-specific UI.
*/
-export interface NavbarContext {
- navItems: ReconciliationNavItem[];
+export interface NavbarContext<
+ TFinalAnswers,
+ TComparison,
+ TNavItem extends ReconciliationNavItem = ReconciliationNavItem,
+> {
+ navItems: TNavItem[];
currentPage: number;
viewMode: 'questions' | 'summary';
- finalAnswers: unknown;
- comparison: unknown;
+ finalAnswers: TFinalAnswers;
+ comparison: TComparison;
/** Per-page presence: Map */
usersByPage: Map;
@@ -105,10 +114,14 @@ export interface NavbarContext {
/**
* Passed to adapter.SummaryComponent.
*/
-export interface SummaryContext {
- navItems: ReconciliationNavItem[];
- finalAnswers: unknown;
- comparison: unknown;
+export interface SummaryContext<
+ TFinalAnswers,
+ TComparison,
+ TNavItem extends ReconciliationNavItem = ReconciliationNavItem,
+> {
+ navItems: TNavItem[];
+ finalAnswers: TFinalAnswers;
+ comparison: TComparison;
summaryStats: ReconciliationSummaryStats;
allAnswered: boolean;
saving: boolean;
@@ -137,7 +150,12 @@ export interface ReconciliationSummaryStats {
* behavior: data derivation, answer checking, write operations, and rendering.
* Registered in the CHECKLIST_REGISTRY under a `reconciliation` key.
*/
-export interface ReconciliationAdapter {
+export interface ReconciliationAdapter<
+ TChecklist,
+ TFinalAnswers = TChecklist,
+ TComparison = unknown,
+ TNavItem extends ReconciliationNavItem = ReconciliationNavItem,
+> {
// --- Identity ---
checklistType: string;
@@ -156,29 +174,33 @@ export interface ReconciliationAdapter {
* ROBINS-I: reads sectionC.isPerProtocol to determine domain 1A/1B.
* AMSTAR2: static list from getQuestionKeys().
*/
- buildNavItems: (reconciledChecklist: unknown) => ReconciliationNavItem[];
+ buildNavItems: (reconciledChecklist: TChecklist) => TNavItem[];
/**
* Derive the finalAnswers object from reconciledChecklist.
* AMSTAR2: filters by questionKeys, groups multi-part questions.
* ROB2/ROBINS-I: returns reconciledChecklist || {} (direct pass-through).
*/
- deriveFinalAnswers: (reconciledChecklist: unknown) => unknown;
+ deriveFinalAnswers: (reconciledChecklist: TChecklist) => TFinalAnswers;
/**
* Run the type-specific comparison algorithm.
* The engine stores the result and passes it to isAgreement, renderPage,
* NavbarComponent, and SummaryComponent.
*/
- compare: (checklist1: unknown, checklist2: unknown, reconciledChecklist: unknown) => unknown;
+ compare: (
+ checklist1: TChecklist,
+ checklist2: TChecklist,
+ reconciledChecklist: TChecklist,
+ ) => TComparison;
// --- Answer checking (pure functions) ---
/** Whether this nav item has a committed final answer */
- hasAnswer: (item: ReconciliationNavItem, finalAnswers: unknown) => boolean;
+ hasAnswer: (item: TNavItem, finalAnswers: TFinalAnswers) => boolean;
/** Whether reviewers agreed on this nav item */
- isAgreement: (item: ReconciliationNavItem, comparison: unknown) => boolean;
+ isAgreement: (item: TNavItem, comparison: TComparison) => boolean;
// --- Write operations ---
@@ -187,8 +209,8 @@ export interface ReconciliationAdapter {
* For ROB2/ROBINS-I, also copies comment text via setTextValue.
*/
autoFillFromReviewer1: (
- item: ReconciliationNavItem,
- checklist1: unknown,
+ item: TNavItem,
+ checklist1: TChecklist,
updateChecklistAnswer: (sectionKey: string, data: unknown) => void,
setTextValue: (ref: TextRef, text: string) => void,
) => void;
@@ -202,8 +224,8 @@ export interface ReconciliationAdapter {
* ROBINS-I will use this when scoring-based skip detection is added.
*/
onAfterNavigate?: (
- navItems: ReconciliationNavItem[],
- finalAnswers: unknown,
+ navItems: TNavItem[],
+ finalAnswers: TFinalAnswers,
updateChecklistAnswer: (sectionKey: string, data: unknown) => void,
) => void;
@@ -214,28 +236,30 @@ export interface ReconciliationAdapter {
* The adapter slices reviewer data from raw checklists, builds write
* callbacks, and composes the existing page components.
*/
- renderPage: (context: EngineContext) => ReactNode;
+ renderPage: (
+ context: EngineContext,
+ ) => ReactNode;
/**
* Type-specific navbar component.
* AMSTAR2: flat question pills. ROB2/ROBINS-I: grouped domain pills.
*/
- NavbarComponent: React.ComponentType;
+ NavbarComponent: React.ComponentType>;
/**
* Type-specific summary view.
* AMSTAR2's includes a reconciledName input field.
*/
- SummaryComponent: React.ComponentType;
+ SummaryComponent: React.ComponentType>;
/**
* Optional: warning banner above the page content.
* ROB2: aim mismatch warning. ROBINS-I: section B critical risk.
*/
renderWarningBanner?: (
- checklist1: unknown,
- checklist2: unknown,
- reconciledChecklist: unknown,
+ checklist1: TChecklist,
+ checklist2: TChecklist,
+ reconciledChecklist: TChecklist,
) => ReactNode | null;
}
diff --git a/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts b/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts
index 2e58a4e2d..5e87ae6fd 100644
--- a/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts
+++ b/packages/web/src/components/project/reconcile-tab/engine/useReconciliationEngine.ts
@@ -17,7 +17,7 @@ import type {
import type { TextRef } from '@/primitives/useProject/checklists';
interface UseReconciliationEngineOptions {
- adapter: ReconciliationAdapter;
+ adapter: ReconciliationAdapter;
checklist1: unknown;
checklist2: unknown;
reconciledChecklist: unknown;
diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/adapter.tsx b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/adapter.tsx
index 51c995dfc..dd22441dc 100644
--- a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/adapter.tsx
+++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/adapter.tsx
@@ -9,7 +9,6 @@
import { AlertTriangleIcon } from 'lucide-react';
import type {
ReconciliationAdapter,
- ReconciliationNavItem,
EngineContext,
NavbarContext,
SummaryContext,
@@ -21,12 +20,14 @@ import {
getActiveDomainKeys,
getDomainQuestions,
scoreRob2Domain,
+ type ComparisonResult,
} from '@corates/shared/checklists/rob2';
import {
buildNavigationItems,
hasNavItemAnswer as rob2HasNavItemAnswer,
isNavItemAgreement as rob2IsNavItemAgreement,
NAV_ITEM_TYPES,
+ type Rob2NavItem,
} from './navbar-utils.js';
import { PreliminaryPage } from './pages/PreliminaryPage';
import { SignallingQuestionPage } from './pages/SignallingQuestionPage';
@@ -82,7 +83,7 @@ function updateOverallDirection(
function getSkippableQuestions(
finalAnswers: any,
isAdhering: boolean,
- navItems: any[],
+ navItems: Rob2NavItem[],
): Set {
const activeDomains = getActiveDomainKeys(isAdhering);
const earlyCompleteDomains = new Set();
@@ -94,9 +95,9 @@ function getSkippableQuestions(
const scoring = scoreRob2Domain(domainKey, domainAnswers);
if (scoring.isComplete && scoring.judgement !== null) {
const items = navItems.filter(
- (item: any) => item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION && item.domainKey === domainKey,
+ item => item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION && item.domainKey === domainKey,
);
- const hasSkippedQuestion = items.some((item: any) => {
+ const hasSkippedQuestion = items.some(item => {
const answer = domainAnswers[item.key]?.answer;
return !answer || answer === 'NA';
});
@@ -110,7 +111,7 @@ function getSkippableQuestions(
for (const domainKey of earlyCompleteDomains) {
const domainAnswers = finalAnswers[domainKey].answers;
const items = navItems.filter(
- (item: any) => item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION && item.domainKey === domainKey,
+ item => item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION && item.domainKey === domainKey,
);
for (const item of items) {
const answer = domainAnswers[item.key]?.answer;
@@ -127,63 +128,62 @@ function getSkippableQuestions(
// Comparison helpers
// ---------------------------------------------------------------------------
-function getCurrentItemComparison(item: any, comparison: any): any {
+function getCurrentItemComparison(
+ item: Rob2NavItem | null,
+ comparison: ComparisonResult | null,
+): any {
if (!item || !comparison) return null;
- if (item.type === NAV_ITEM_TYPES.PRELIMINARY) {
- return comparison.preliminary?.fields?.find((f: any) => f.key === item.key);
- }
- if (item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION) {
- const domain = comparison.domains?.[item.domainKey];
- if (!domain) return null;
- const allItems = [
- ...(domain.questions?.agreements || []),
- ...(domain.questions?.disagreements || []),
- ];
- return allItems.find((c: any) => c.key === item.key);
- }
- if (item.type === NAV_ITEM_TYPES.DOMAIN_DIRECTION) {
- return comparison.domains?.[item.domainKey];
- }
- if (item.type === NAV_ITEM_TYPES.OVERALL_DIRECTION) {
- return comparison.overall;
+ switch (item.type) {
+ case NAV_ITEM_TYPES.PRELIMINARY:
+ return comparison.preliminary?.fields?.find(f => f.key === item.key);
+ case NAV_ITEM_TYPES.DOMAIN_QUESTION: {
+ const domain = comparison.domains?.[item.domainKey];
+ if (!domain) return null;
+ return [...domain.questions.agreements, ...domain.questions.disagreements].find(
+ c => c.key === item.key,
+ );
+ }
+ case NAV_ITEM_TYPES.DOMAIN_DIRECTION:
+ return comparison.domains?.[item.domainKey];
+ case NAV_ITEM_TYPES.OVERALL_DIRECTION:
+ return comparison.overall;
}
- return null;
}
// ---------------------------------------------------------------------------
// Adapter: data derivation
// ---------------------------------------------------------------------------
-function buildNavItems(reconciledChecklist: unknown): ReconciliationNavItem[] {
- const rc = reconciledChecklist as any;
- const isAdhering = rc?.preliminary?.aim === 'ADHERING';
- const items = buildNavigationItems(isAdhering);
- // The existing buildNavigationItems already returns objects with type, key, label,
- // section, domainKey - which map directly to ReconciliationNavItem
- return items as ReconciliationNavItem[];
+function buildNavItems(reconciledChecklist: any): Rob2NavItem[] {
+ const isAdhering = reconciledChecklist?.preliminary?.aim === 'ADHERING';
+ return buildNavigationItems(isAdhering);
}
-function deriveFinalAnswers(reconciledChecklist: unknown): unknown {
+function deriveFinalAnswers(reconciledChecklist: any): any {
return reconciledChecklist || {};
}
-function compare(checklist1: unknown, checklist2: unknown, reconciledChecklist: unknown): unknown {
+function compare(
+ checklist1: any,
+ checklist2: any,
+ reconciledChecklist: any,
+): ComparisonResult | null {
if (!checklist1 || !checklist2) return null;
- const reconciledAim = (reconciledChecklist as any)?.preliminary?.aim;
- return compareChecklists(checklist1 as any, checklist2 as any, reconciledAim);
+ const reconciledAim = reconciledChecklist?.preliminary?.aim;
+ return compareChecklists(checklist1, checklist2, reconciledAim);
}
// ---------------------------------------------------------------------------
// Adapter: answer checking
// ---------------------------------------------------------------------------
-function hasAnswer(item: ReconciliationNavItem, finalAnswers: unknown): boolean {
- return rob2HasNavItemAnswer(item as any, finalAnswers as any);
+function hasAnswer(item: Rob2NavItem, finalAnswers: any): boolean {
+ return rob2HasNavItemAnswer(item, finalAnswers);
}
-function isAgreement(item: ReconciliationNavItem, comparison: unknown): boolean {
- return rob2IsNavItemAgreement(item as any, comparison as any);
+function isAgreement(item: Rob2NavItem, comparison: ComparisonResult | null): boolean {
+ return rob2IsNavItemAgreement(item, comparison);
}
// ---------------------------------------------------------------------------
@@ -191,16 +191,15 @@ function isAgreement(item: ReconciliationNavItem, comparison: unknown): boolean
// ---------------------------------------------------------------------------
function autoFillFromReviewer1(
- item: ReconciliationNavItem,
- checklist1: unknown,
+ item: Rob2NavItem,
+ checklist1: any,
updateChecklistAnswer: (sectionKey: string, data: unknown) => void,
setTextValue: (ref: TextRef, text: string) => void,
): void {
- const c1 = checklist1 as any;
-
- if (item.type === NAV_ITEM_TYPES.PRELIMINARY) {
- const value = c1?.preliminary?.[item.key];
- if (value !== undefined) {
+ switch (item.type) {
+ case NAV_ITEM_TYPES.PRELIMINARY: {
+ const value = checklist1?.preliminary?.[item.key];
+ if (value === undefined) return;
// Always update finalAnswers so hasNavItemAnswer works even if page is unmounted
updatePreliminaryField(updateChecklistAnswer, item.key, value);
if (PRELIMINARY_TEXT_FIELDS.includes(item.key)) {
@@ -209,10 +208,11 @@ function autoFillFromReviewer1(
typeof value === 'string' ? value : '',
);
}
+ return;
}
- } else if (item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION && item.domainKey) {
- const answer = c1?.[item.domainKey]?.answers?.[item.key];
- if (answer) {
+ case NAV_ITEM_TYPES.DOMAIN_QUESTION: {
+ const answer = checklist1?.[item.domainKey]?.answers?.[item.key];
+ if (!answer) return;
updateDomainQuestionAnswer(updateChecklistAnswer, item.domainKey, item.key, answer.answer);
setTextValue(
{
@@ -223,16 +223,17 @@ function autoFillFromReviewer1(
},
answer.comment || '',
);
+ return;
}
- } else if (item.type === NAV_ITEM_TYPES.DOMAIN_DIRECTION && item.domainKey) {
- const direction = c1?.[item.domainKey]?.direction;
- if (direction) {
- updateDomainDirection(updateChecklistAnswer, item.domainKey!, direction);
+ case NAV_ITEM_TYPES.DOMAIN_DIRECTION: {
+ const direction = checklist1?.[item.domainKey]?.direction;
+ if (direction) updateDomainDirection(updateChecklistAnswer, item.domainKey, direction);
+ return;
}
- } else if (item.type === NAV_ITEM_TYPES.OVERALL_DIRECTION) {
- const direction = c1?.overall?.direction;
- if (direction) {
- updateOverallDirection(updateChecklistAnswer, direction);
+ case NAV_ITEM_TYPES.OVERALL_DIRECTION: {
+ const direction = checklist1?.overall?.direction;
+ if (direction) updateOverallDirection(updateChecklistAnswer, direction);
+ return;
}
}
}
@@ -273,23 +274,25 @@ function resetAllAnswers(updateChecklistAnswer: (sectionKey: string, data: unkno
// ---------------------------------------------------------------------------
function onAfterNavigate(
- navItems: ReconciliationNavItem[],
- finalAnswers: unknown,
+ navItems: Rob2NavItem[],
+ finalAnswers: any,
updateChecklistAnswer: (sectionKey: string, data: unknown) => void,
): void {
- const fa = finalAnswers as any;
- const isAdhering = fa?.preliminary?.aim === 'ADHERING';
- const skippable = getSkippableQuestions(fa, isAdhering, navItems as any[]);
+ const isAdhering = finalAnswers?.preliminary?.aim === 'ADHERING';
+ const skippable = getSkippableQuestions(finalAnswers, isAdhering, navItems);
if (skippable.size === 0) return;
for (const qKey of skippable) {
- const item = navItems.find(i => i.type === NAV_ITEM_TYPES.DOMAIN_QUESTION && i.key === qKey);
+ const item = navItems.find(
+ (i): i is Extract =>
+ i.type === NAV_ITEM_TYPES.DOMAIN_QUESTION && i.key === qKey,
+ );
if (!item) continue;
- const currentAnswer = fa[item.domainKey!]?.answers?.[qKey]?.answer;
+ const currentAnswer = finalAnswers[item.domainKey]?.answers?.[qKey]?.answer;
if (currentAnswer == null) {
- updateDomainQuestionAnswer(updateChecklistAnswer, item.domainKey!, qKey, 'NA');
+ updateDomainQuestionAnswer(updateChecklistAnswer, item.domainKey, qKey, 'NA');
}
}
}
@@ -298,14 +301,18 @@ function onAfterNavigate(
// Adapter: renderPage
// ---------------------------------------------------------------------------
-function renderPage(context: EngineContext) {
- const { currentItem, checklist1, checklist2, finalAnswers, comparison, getTextRef } = context;
- const c1 = checklist1 as any;
- const c2 = checklist2 as any;
- const fa = finalAnswers as any;
+function renderPage(context: EngineContext) {
+ const {
+ currentItem,
+ checklist1: c1,
+ checklist2: c2,
+ finalAnswers: fa,
+ comparison,
+ getTextRef,
+ } = context;
const itemComparison = getCurrentItemComparison(currentItem, comparison);
const isAdhering = fa?.preliminary?.aim === 'ADHERING';
- const skippable = getSkippableQuestions(fa, isAdhering, context.navItems as any[]);
+ const skippable = getSkippableQuestions(fa, isAdhering, context.navItems);
if (currentItem.type === NAV_ITEM_TYPES.PRELIMINARY) {
return (
@@ -357,102 +364,83 @@ function renderPage(context: EngineContext) {
}
if (currentItem.type === NAV_ITEM_TYPES.DOMAIN_QUESTION) {
+ const { domainKey, key: questionKey } = currentItem;
return (
- updateDomainQuestionAnswer(
- context.updateChecklistAnswer,
- currentItem.domainKey!,
- currentItem.key,
- answer,
- )
+ updateDomainQuestionAnswer(context.updateChecklistAnswer, domainKey, questionKey, answer)
}
onUseReviewer1={() => {
- const data = c1?.[currentItem.domainKey!]?.answers?.[currentItem.key];
- if (data) {
- updateDomainQuestionAnswer(
- context.updateChecklistAnswer,
- currentItem.domainKey!,
- currentItem.key,
- data.answer,
- );
- context.setTextValue(
- {
- type: 'ROB2',
- sectionKey: currentItem.domainKey!,
- fieldKey: 'comment',
- questionKey: currentItem.key,
- },
- data.comment || '',
- );
- }
+ const data = c1?.[domainKey]?.answers?.[questionKey];
+ if (!data) return;
+ updateDomainQuestionAnswer(
+ context.updateChecklistAnswer,
+ domainKey,
+ questionKey,
+ data.answer,
+ );
+ context.setTextValue(
+ { type: 'ROB2', sectionKey: domainKey, fieldKey: 'comment', questionKey },
+ data.comment || '',
+ );
}}
onUseReviewer2={() => {
- const data = c2?.[currentItem.domainKey!]?.answers?.[currentItem.key];
- if (data) {
- updateDomainQuestionAnswer(
- context.updateChecklistAnswer,
- currentItem.domainKey!,
- currentItem.key,
- data.answer,
- );
- context.setTextValue(
- {
- type: 'ROB2',
- sectionKey: currentItem.domainKey!,
- fieldKey: 'comment',
- questionKey: currentItem.key,
- },
- data.comment || '',
- );
- }
+ const data = c2?.[domainKey]?.answers?.[questionKey];
+ if (!data) return;
+ updateDomainQuestionAnswer(
+ context.updateChecklistAnswer,
+ domainKey,
+ questionKey,
+ data.answer,
+ );
+ context.setTextValue(
+ { type: 'ROB2', sectionKey: domainKey, fieldKey: 'comment', questionKey },
+ data.comment || '',
+ );
}}
/>
);
}
if (currentItem.type === NAV_ITEM_TYPES.DOMAIN_DIRECTION) {
+ const { domainKey } = currentItem;
return (
- updateDomainDirection(context.updateChecklistAnswer, currentItem.domainKey!, direction)
+ updateDomainDirection(context.updateChecklistAnswer, domainKey, direction)
}
onUseReviewer1={() => {
- const direction = c1?.[currentItem.domainKey!]?.direction;
- if (direction) {
- updateDomainDirection(context.updateChecklistAnswer, currentItem.domainKey!, direction);
- }
+ const direction = c1?.[domainKey]?.direction;
+ if (direction) updateDomainDirection(context.updateChecklistAnswer, domainKey, direction);
}}
onUseReviewer2={() => {
- const direction = c2?.[currentItem.domainKey!]?.direction;
- if (direction) {
- updateDomainDirection(context.updateChecklistAnswer, currentItem.domainKey!, direction);
- }
+ const direction = c2?.[domainKey]?.direction;
+ if (direction) updateDomainDirection(context.updateChecklistAnswer, domainKey, direction);
}}
/>
);
@@ -496,15 +484,17 @@ function renderPage(context: EngineContext) {
// Adapter: NavbarComponent wrapper
// ---------------------------------------------------------------------------
-function Rob2NavbarAdapter(navbarContext: NavbarContext) {
- const fa = navbarContext.finalAnswers as any;
+function Rob2NavbarAdapter(
+ navbarContext: NavbarContext,
+) {
+ const fa = navbarContext.finalAnswers;
const isAdhering = fa?.preliminary?.aim === 'ADHERING';
- const skippable = getSkippableQuestions(fa, isAdhering, navbarContext.navItems as any[]);
+ const skippable = getSkippableQuestions(fa, isAdhering, navbarContext.navItems);
// Derive aimMismatch from comparison data (the comparison already knows if reviewers
// disagree on aim) and finalAnswers (mismatch is resolved once a final aim is set)
- const comp = navbarContext.comparison as any;
- const aimField = comp?.preliminary?.fields?.find((f: any) => f.key === 'aim');
+ const comp = navbarContext.comparison;
+ const aimField = comp?.preliminary?.fields?.find(f => f.key === 'aim');
const aimMismatch = aimField ? !aimField.isAgreement && !fa?.preliminary?.aim : false;
const store = {
@@ -529,7 +519,9 @@ function Rob2NavbarAdapter(navbarContext: NavbarContext) {
// Adapter: SummaryComponent wrapper
// ---------------------------------------------------------------------------
-function Rob2SummaryAdapter(summaryContext: SummaryContext) {
+function Rob2SummaryAdapter(
+ summaryContext: SummaryContext,
+) {
return (
= {
checklistType: 'ROB2',
title: 'ROB-2 Reconciliation',
pageCounterLabel: 'Item',
diff --git a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/navbar-utils.ts b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/navbar-utils.ts
index fd47384a8..c6d005ef6 100644
--- a/packages/web/src/components/project/reconcile-tab/rob2-reconcile/navbar-utils.ts
+++ b/packages/web/src/components/project/reconcile-tab/rob2-reconcile/navbar-utils.ts
@@ -19,20 +19,35 @@ export const NAV_ITEM_TYPES = {
OVERALL_DIRECTION: 'overallDirection',
} as const;
-type NavItemType = (typeof NAV_ITEM_TYPES)[keyof typeof NAV_ITEM_TYPES];
-
-interface NavItem {
- type: NavItemType;
+interface NavItemBase {
key: string;
label: string;
section: string;
sectionKey: string;
- domainKey?: string;
- fieldDef?: (typeof PRELIMINARY_SECTION)[keyof typeof PRELIMINARY_SECTION];
- questionDef?: Record;
- isDirection?: boolean;
}
+export type Rob2NavItem =
+ | (NavItemBase & {
+ type: typeof NAV_ITEM_TYPES.PRELIMINARY;
+ fieldDef?: (typeof PRELIMINARY_SECTION)[keyof typeof PRELIMINARY_SECTION];
+ })
+ | (NavItemBase & {
+ type: typeof NAV_ITEM_TYPES.DOMAIN_QUESTION;
+ domainKey: string;
+ questionDef?: Record;
+ })
+ | (NavItemBase & {
+ type: typeof NAV_ITEM_TYPES.DOMAIN_DIRECTION;
+ domainKey: string;
+ isDirection: true;
+ })
+ | (NavItemBase & {
+ type: typeof NAV_ITEM_TYPES.OVERALL_DIRECTION;
+ isDirection: true;
+ });
+
+type NavItem = Rob2NavItem;
+
interface NavGroup {
section: string;
items: NavItem[];
@@ -232,13 +247,11 @@ export function hasNavItemAnswer(navItem: NavItem, finalAnswers: FinalAnswers):
case NAV_ITEM_TYPES.PRELIMINARY:
return hasPreliminaryAnswer(navItem.key, finalAnswers);
case NAV_ITEM_TYPES.DOMAIN_QUESTION:
- return hasDomainQuestionAnswer(navItem.domainKey!, navItem.key, finalAnswers);
+ return hasDomainQuestionAnswer(navItem.domainKey, navItem.key, finalAnswers);
case NAV_ITEM_TYPES.DOMAIN_DIRECTION:
- return hasDomainDirection(navItem.domainKey!, finalAnswers);
+ return hasDomainDirection(navItem.domainKey, finalAnswers);
case NAV_ITEM_TYPES.OVERALL_DIRECTION:
return hasOverallDirection(finalAnswers);
- default:
- return false;
}
}
@@ -254,20 +267,18 @@ export function isNavItemAgreement(navItem: NavItem, comparison: Comparison | nu
return field?.isAgreement ?? false;
}
case NAV_ITEM_TYPES.DOMAIN_QUESTION: {
- const domain = comparison.domains?.[navItem.domainKey!];
+ const domain = comparison.domains?.[navItem.domainKey];
if (!domain) return false;
const found = domain.questions?.agreements?.find(a => a.key === navItem.key);
return !!found;
}
case NAV_ITEM_TYPES.DOMAIN_DIRECTION: {
- const domain = comparison.domains?.[navItem.domainKey!];
+ const domain = comparison.domains?.[navItem.domainKey];
return domain?.directionMatch ?? false;
}
case NAV_ITEM_TYPES.OVERALL_DIRECTION: {
return comparison.overall?.directionMatch ?? false;
}
- default:
- return false;
}
}
diff --git a/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/NavbarDomainPill.tsx b/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/NavbarDomainPill.tsx
index 2554295e3..dc8f1be35 100644
--- a/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/NavbarDomainPill.tsx
+++ b/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/NavbarDomainPill.tsx
@@ -8,17 +8,10 @@ import {
getNavItemPillStyle,
getNavItemTooltip,
NAV_ITEM_TYPES,
+ type RobinsINavItem,
} from './navbar-utils.js';
-interface NavItem {
- type: string;
- key: string;
- label: string;
- section: string;
- domainKey?: string;
- isJudgement?: boolean;
- [key: string]: any;
-}
+type NavItem = RobinsINavItem;
interface ProgressInfo {
answered: number;
@@ -165,20 +158,17 @@ function QuestionPill({
const tooltip = getNavItemTooltip(item, hasAnswer, isAgreement);
const displayLabel = (() => {
- if (item.type === NAV_ITEM_TYPES.SECTION_B) {
- return item.key.replace('b', '');
- }
- if (item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION) {
- const parts = item.label.split('.');
- return parts.length > 1 ? parts[1] : item.label;
- }
- if (item.type === NAV_ITEM_TYPES.DOMAIN_JUDGEMENT) {
- return 'J';
- }
- if (item.type === NAV_ITEM_TYPES.OVERALL_JUDGEMENT) {
- return 'J';
+ switch (item.type) {
+ case NAV_ITEM_TYPES.SECTION_B:
+ return item.key.replace('b', '');
+ case NAV_ITEM_TYPES.DOMAIN_QUESTION: {
+ const parts = item.label.split('.');
+ return parts.length > 1 ? parts[1] : item.label;
+ }
+ case NAV_ITEM_TYPES.DOMAIN_JUDGEMENT:
+ case NAV_ITEM_TYPES.OVERALL_JUDGEMENT:
+ return 'J';
}
- return item.label;
})();
const isJudgement =
diff --git a/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/RobinsINavbar.tsx b/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/RobinsINavbar.tsx
index db4aac004..c1f9453f0 100644
--- a/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/RobinsINavbar.tsx
+++ b/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/RobinsINavbar.tsx
@@ -6,20 +6,11 @@ import {
getDomainProgress,
getSectionKeyForPage,
getFirstUnansweredInSection,
+ type RobinsINavItem,
} from './navbar-utils.js';
-interface NavItem {
- type: string;
- key: string;
- label: string;
- section: string;
- domainKey?: string;
- isJudgement?: boolean;
- [key: string]: any;
-}
-
interface NavbarStore {
- navItems: NavItem[];
+ navItems: RobinsINavItem[];
viewMode: string;
currentPage: number;
comparison: any;
diff --git a/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/adapter.tsx b/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/adapter.tsx
index 98b1a4abd..dfb1f9c91 100644
--- a/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/adapter.tsx
+++ b/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/adapter.tsx
@@ -9,7 +9,6 @@
import { AlertTriangleIcon } from 'lucide-react';
import type {
ReconciliationAdapter,
- ReconciliationNavItem,
EngineContext,
NavbarContext,
SummaryContext,
@@ -19,6 +18,7 @@ import {
compareChecklists,
getSectionBKeys,
getDomainKeysForComparison,
+ type ComparisonResult,
} from '@/components/checklist/ROBINSIChecklist/checklist-compare.js';
import {
buildNavigationItems,
@@ -26,6 +26,7 @@ import {
isNavItemAgreement as robinsIsNavItemAgreement,
isSectionBCritical,
NAV_ITEM_TYPES,
+ type RobinsINavItem,
} from './navbar-utils.js';
import { SectionBQuestionPage } from './pages/SectionBQuestionPage';
import { DomainQuestionPage } from './pages/DomainQuestionPage';
@@ -84,66 +85,62 @@ function updateOverallJudgement(
// Comparison helper
// ---------------------------------------------------------------------------
-function getCurrentItemComparison(item: any, comparison: any): any {
+function getCurrentItemComparison(
+ item: RobinsINavItem | null,
+ comparison: ComparisonResult | null,
+): any {
if (!item || !comparison) return null;
- if (item.type === NAV_ITEM_TYPES.SECTION_B) {
- const allItems = [
- ...(comparison.sectionB?.agreements || []),
- ...(comparison.sectionB?.disagreements || []),
- ];
- return allItems.find((c: any) => c.key === item.key);
- }
- if (item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION) {
- const domain = comparison.domains?.[item.domainKey];
- if (!domain) return null;
- const allItems = [
- ...(domain.questions?.agreements || []),
- ...(domain.questions?.disagreements || []),
- ];
- return allItems.find((c: any) => c.key === item.key);
- }
- if (item.type === NAV_ITEM_TYPES.DOMAIN_JUDGEMENT) {
- return comparison.domains?.[item.domainKey];
- }
- if (item.type === NAV_ITEM_TYPES.OVERALL_JUDGEMENT) {
- return comparison.overall;
+ switch (item.type) {
+ case NAV_ITEM_TYPES.SECTION_B:
+ return [...comparison.sectionB.agreements, ...comparison.sectionB.disagreements].find(
+ c => c.key === item.key,
+ );
+ case NAV_ITEM_TYPES.DOMAIN_QUESTION: {
+ const domain = comparison.domains?.[item.domainKey];
+ if (!domain) return null;
+ return [...domain.questions.agreements, ...domain.questions.disagreements].find(
+ c => c.key === item.key,
+ );
+ }
+ case NAV_ITEM_TYPES.DOMAIN_JUDGEMENT:
+ return comparison.domains?.[item.domainKey];
+ case NAV_ITEM_TYPES.OVERALL_JUDGEMENT:
+ return comparison.overall;
}
- return null;
}
// ---------------------------------------------------------------------------
// Adapter: data derivation
// ---------------------------------------------------------------------------
-function buildNavItems(reconciledChecklist: unknown): ReconciliationNavItem[] {
+function buildNavItems(reconciledChecklist: any): RobinsINavItem[] {
// ROBINS-I uses checklist1's sectionC to determine per-protocol, but
// reconciledChecklist may inherit it. For now use false as default.
// The engine passes reconciledChecklist; we check sectionC if available.
- const rc = reconciledChecklist as any;
- const isPerProtocol = rc?.sectionC?.isPerProtocol || false;
- return buildNavigationItems(isPerProtocol) as ReconciliationNavItem[];
+ const isPerProtocol = reconciledChecklist?.sectionC?.isPerProtocol || false;
+ return buildNavigationItems(isPerProtocol);
}
-function deriveFinalAnswers(reconciledChecklist: unknown): unknown {
+function deriveFinalAnswers(reconciledChecklist: any): any {
return reconciledChecklist || {};
}
-function compare(checklist1: unknown, checklist2: unknown): unknown {
+function compare(checklist1: any, checklist2: any): ComparisonResult | null {
if (!checklist1 || !checklist2) return null;
- return compareChecklists(checklist1 as any, checklist2 as any);
+ return compareChecklists(checklist1, checklist2);
}
// ---------------------------------------------------------------------------
// Adapter: answer checking
// ---------------------------------------------------------------------------
-function hasAnswer(item: ReconciliationNavItem, finalAnswers: unknown): boolean {
- return robinsHasNavItemAnswer(item as any, finalAnswers as any);
+function hasAnswer(item: RobinsINavItem, finalAnswers: any): boolean {
+ return robinsHasNavItemAnswer(item, finalAnswers);
}
-function isAgreement(item: ReconciliationNavItem, comparison: unknown): boolean {
- return robinsIsNavItemAgreement(item as any, comparison as any);
+function isAgreement(item: RobinsINavItem, comparison: ComparisonResult | null): boolean {
+ return robinsIsNavItemAgreement(item, comparison as any);
}
// ---------------------------------------------------------------------------
@@ -151,30 +148,25 @@ function isAgreement(item: ReconciliationNavItem, comparison: unknown): boolean
// ---------------------------------------------------------------------------
function autoFillFromReviewer1(
- item: ReconciliationNavItem,
- checklist1: unknown,
+ item: RobinsINavItem,
+ checklist1: any,
updateChecklistAnswer: (sectionKey: string, data: unknown) => void,
setTextValue: (ref: TextRef, text: string) => void,
): void {
- const c1 = checklist1 as any;
-
- if (item.type === NAV_ITEM_TYPES.SECTION_B) {
- const answer = c1?.sectionB?.[item.key];
- if (answer) {
+ switch (item.type) {
+ case NAV_ITEM_TYPES.SECTION_B: {
+ const answer = checklist1?.sectionB?.[item.key];
+ if (!answer) return;
updateSectionBAnswer(updateChecklistAnswer, item.key, answer.answer);
setTextValue(
- {
- type: 'ROBINS_I',
- sectionKey: 'sectionB',
- fieldKey: 'comment',
- questionKey: item.key,
- },
+ { type: 'ROBINS_I', sectionKey: 'sectionB', fieldKey: 'comment', questionKey: item.key },
answer.comment || '',
);
+ return;
}
- } else if (item.type === NAV_ITEM_TYPES.DOMAIN_QUESTION && item.domainKey) {
- const answer = c1?.[item.domainKey]?.answers?.[item.key];
- if (answer) {
+ case NAV_ITEM_TYPES.DOMAIN_QUESTION: {
+ const answer = checklist1?.[item.domainKey]?.answers?.[item.key];
+ if (!answer) return;
updateDomainQuestionAnswer(updateChecklistAnswer, item.domainKey, item.key, answer.answer);
setTextValue(
{
@@ -185,21 +177,26 @@ function autoFillFromReviewer1(
},
answer.comment || '',
);
+ return;
}
- } else if (item.type === NAV_ITEM_TYPES.DOMAIN_JUDGEMENT && item.domainKey) {
- const domain = c1?.[item.domainKey];
- if (domain?.judgement) {
- updateDomainJudgement(
- updateChecklistAnswer,
- item.domainKey,
- domain.judgement,
- domain.direction,
- );
+ case NAV_ITEM_TYPES.DOMAIN_JUDGEMENT: {
+ const domain = checklist1?.[item.domainKey];
+ if (domain?.judgement) {
+ updateDomainJudgement(
+ updateChecklistAnswer,
+ item.domainKey,
+ domain.judgement,
+ domain.direction,
+ );
+ }
+ return;
}
- } else if (item.type === NAV_ITEM_TYPES.OVERALL_JUDGEMENT) {
- const overall = c1?.overall;
- if (overall?.judgement) {
- updateOverallJudgement(updateChecklistAnswer, overall.judgement, overall.direction);
+ case NAV_ITEM_TYPES.OVERALL_JUDGEMENT: {
+ const overall = checklist1?.overall;
+ if (overall?.judgement) {
+ updateOverallJudgement(updateChecklistAnswer, overall.judgement, overall.direction);
+ }
+ return;
}
}
}
@@ -233,11 +230,15 @@ function resetAllAnswers(updateChecklistAnswer: (sectionKey: string, data: unkno
// Adapter: renderPage
// ---------------------------------------------------------------------------
-function renderPage(context: EngineContext) {
- const { currentItem, checklist1, checklist2, finalAnswers, comparison, getTextRef } = context;
- const c1 = checklist1 as any;
- const c2 = checklist2 as any;
- const fa = finalAnswers as any;
+function renderPage(context: EngineContext) {
+ const {
+ currentItem,
+ checklist1: c1,
+ checklist2: c2,
+ finalAnswers: fa,
+ comparison,
+ getTextRef,
+ } = context;
const itemComparison = getCurrentItemComparison(currentItem, comparison);
if (currentItem.type === NAV_ITEM_TYPES.SECTION_B) {
@@ -293,82 +294,67 @@ function renderPage(context: EngineContext) {
);
}
- if (currentItem.type === NAV_ITEM_TYPES.DOMAIN_QUESTION && currentItem.domainKey) {
+ if (currentItem.type === NAV_ITEM_TYPES.DOMAIN_QUESTION) {
+ const { domainKey, key: questionKey } = currentItem;
return (
- updateDomainQuestionAnswer(
- context.updateChecklistAnswer,
- currentItem.domainKey!,
- currentItem.key,
- answer,
- )
+ updateDomainQuestionAnswer(context.updateChecklistAnswer, domainKey, questionKey, answer)
}
onUseReviewer1={() => {
- const data = c1?.[currentItem.domainKey!]?.answers?.[currentItem.key];
- if (data) {
- updateDomainQuestionAnswer(
- context.updateChecklistAnswer,
- currentItem.domainKey!,
- currentItem.key,
- data.answer,
- );
- context.setTextValue(
- {
- type: 'ROBINS_I',
- sectionKey: currentItem.domainKey!,
- fieldKey: 'comment',
- questionKey: currentItem.key,
- },
- data.comment || '',
- );
- }
+ const data = c1?.[domainKey]?.answers?.[questionKey];
+ if (!data) return;
+ updateDomainQuestionAnswer(
+ context.updateChecklistAnswer,
+ domainKey,
+ questionKey,
+ data.answer,
+ );
+ context.setTextValue(
+ { type: 'ROBINS_I', sectionKey: domainKey, fieldKey: 'comment', questionKey },
+ data.comment || '',
+ );
}}
onUseReviewer2={() => {
- const data = c2?.[currentItem.domainKey!]?.answers?.[currentItem.key];
- if (data) {
- updateDomainQuestionAnswer(
- context.updateChecklistAnswer,
- currentItem.domainKey!,
- currentItem.key,
- data.answer,
- );
- context.setTextValue(
- {
- type: 'ROBINS_I',
- sectionKey: currentItem.domainKey!,
- fieldKey: 'comment',
- questionKey: currentItem.key,
- },
- data.comment || '',
- );
- }
+ const data = c2?.[domainKey]?.answers?.[questionKey];
+ if (!data) return;
+ updateDomainQuestionAnswer(
+ context.updateChecklistAnswer,
+ domainKey,
+ questionKey,
+ data.answer,
+ );
+ context.setTextValue(
+ { type: 'ROBINS_I', sectionKey: domainKey, fieldKey: 'comment', questionKey },
+ data.comment || '',
+ );
}}
/>
);
}
- if (currentItem.type === NAV_ITEM_TYPES.DOMAIN_JUDGEMENT && currentItem.domainKey) {
+ if (currentItem.type === NAV_ITEM_TYPES.DOMAIN_JUDGEMENT) {
+ const { domainKey } = currentItem;
return (
updateDomainJudgement(
context.updateChecklistAnswer,
- currentItem.domainKey!,
+ domainKey,
judgement,
- fa[currentItem.domainKey!]?.direction,
+ fa[domainKey]?.direction,
)
}
onFinalDirectionChange={direction =>
updateDomainJudgement(
context.updateChecklistAnswer,
- currentItem.domainKey!,
- fa[currentItem.domainKey!]?.judgement,
+ domainKey,
+ fa[domainKey]?.judgement,
direction,
)
}
onUseReviewer1Judgement={() => {
- const data = c1?.[currentItem.domainKey!];
+ const data = c1?.[domainKey];
if (data)
updateDomainJudgement(
context.updateChecklistAnswer,
- currentItem.domainKey!,
+ domainKey,
data.judgement,
- fa[currentItem.domainKey!]?.direction,
+ fa[domainKey]?.direction,
);
}}
onUseReviewer2Judgement={() => {
- const data = c2?.[currentItem.domainKey!];
+ const data = c2?.[domainKey];
if (data)
updateDomainJudgement(
context.updateChecklistAnswer,
- currentItem.domainKey!,
+ domainKey,
data.judgement,
- fa[currentItem.domainKey!]?.direction,
+ fa[domainKey]?.direction,
);
}}
onUseReviewer1Direction={() => {
- const data = c1?.[currentItem.domainKey!];
+ const data = c1?.[domainKey];
if (data)
updateDomainJudgement(
context.updateChecklistAnswer,
- currentItem.domainKey!,
- fa[currentItem.domainKey!]?.judgement,
+ domainKey,
+ fa[domainKey]?.judgement,
data.direction,
);
}}
onUseReviewer2Direction={() => {
- const data = c2?.[currentItem.domainKey!];
+ const data = c2?.[domainKey];
if (data)
updateDomainJudgement(
context.updateChecklistAnswer,
- currentItem.domainKey!,
- fa[currentItem.domainKey!]?.judgement,
+ domainKey,
+ fa[domainKey]?.judgement,
data.direction,
);
}}
@@ -496,10 +482,11 @@ function renderPage(context: EngineContext) {
// Adapter: NavbarComponent wrapper
// ---------------------------------------------------------------------------
-function RobinsINavbarAdapter(navbarContext: NavbarContext) {
+function RobinsINavbarAdapter(
+ navbarContext: NavbarContext,
+) {
// Recompute sectionBCritical from finalAnswers
- const fa = navbarContext.finalAnswers as any;
- const sectionBCrit = isSectionBCritical(fa?.sectionB);
+ const sectionBCrit = isSectionBCritical(navbarContext.finalAnswers?.sectionB);
const store = {
navItems: navbarContext.navItems,
@@ -522,7 +509,9 @@ function RobinsINavbarAdapter(navbarContext: NavbarContext) {
// Adapter: SummaryComponent wrapper
// ---------------------------------------------------------------------------
-function RobinsISummaryAdapter(summaryContext: SummaryContext) {
+function RobinsISummaryAdapter(
+ summaryContext: SummaryContext,
+) {
return (
= {
checklistType: 'ROBINS_I',
title: 'ROBINS-I Reconciliation',
pageCounterLabel: 'Item',
diff --git a/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/navbar-utils.ts b/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/navbar-utils.ts
index 4b1c75a5a..bc3d9b4aa 100644
--- a/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/navbar-utils.ts
+++ b/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/navbar-utils.ts
@@ -21,18 +21,35 @@ export const NAV_ITEM_TYPES = {
OVERALL_JUDGEMENT: 'overallJudgement',
} as const;
-interface NavItem {
- type: string;
+interface NavItemBase {
key: string;
label: string;
section: string;
- sectionKey?: string;
- domainKey?: string;
- questionDef?: ROBINSQuestion | Record;
- isJudgement?: boolean;
- [key: string]: unknown;
+ sectionKey: string;
}
+export type RobinsINavItem =
+ | (NavItemBase & {
+ type: typeof NAV_ITEM_TYPES.SECTION_B;
+ questionDef?: ROBINSQuestion | Record;
+ })
+ | (NavItemBase & {
+ type: typeof NAV_ITEM_TYPES.DOMAIN_QUESTION;
+ domainKey: string;
+ questionDef?: ROBINSQuestion | Record;
+ })
+ | (NavItemBase & {
+ type: typeof NAV_ITEM_TYPES.DOMAIN_JUDGEMENT;
+ domainKey: string;
+ isJudgement: true;
+ })
+ | (NavItemBase & {
+ type: typeof NAV_ITEM_TYPES.OVERALL_JUDGEMENT;
+ isJudgement: true;
+ });
+
+type NavItem = RobinsINavItem;
+
interface NavGroup {
section: string;
items: NavItem[];
@@ -203,13 +220,11 @@ export function hasNavItemAnswer(navItem: NavItem, finalAnswers: FinalAnswers):
case NAV_ITEM_TYPES.SECTION_B:
return hasSectionBAnswer(navItem.key, finalAnswers);
case NAV_ITEM_TYPES.DOMAIN_QUESTION:
- return hasDomainQuestionAnswer(navItem.domainKey!, navItem.key, finalAnswers);
+ return hasDomainQuestionAnswer(navItem.domainKey, navItem.key, finalAnswers);
case NAV_ITEM_TYPES.DOMAIN_JUDGEMENT:
- return hasDomainJudgement(navItem.domainKey!, finalAnswers);
+ return hasDomainJudgement(navItem.domainKey, finalAnswers);
case NAV_ITEM_TYPES.OVERALL_JUDGEMENT:
return hasOverallJudgement(finalAnswers);
- default:
- return false;
}
}
@@ -225,20 +240,18 @@ export function isNavItemAgreement(navItem: NavItem, comparison: Comparison | nu
return !!found;
}
case NAV_ITEM_TYPES.DOMAIN_QUESTION: {
- const domain = comparison.domains?.[navItem.domainKey!];
+ const domain = comparison.domains?.[navItem.domainKey];
if (!domain) return false;
const found = domain.questions?.agreements?.find(a => a.key === navItem.key);
return !!found;
}
case NAV_ITEM_TYPES.DOMAIN_JUDGEMENT: {
- const domain = comparison.domains?.[navItem.domainKey!];
+ const domain = comparison.domains?.[navItem.domainKey];
return !!(domain?.judgementMatch && domain?.directionMatch);
}
case NAV_ITEM_TYPES.OVERALL_JUDGEMENT: {
return !!(comparison.overall?.judgementMatch && comparison.overall?.directionMatch);
}
- default:
- return false;
}
}