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; } }