From 2ee90dc1ac8947aa7158e1836df475a3f1c1d6ef Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 1 May 2026 10:13:24 -0500 Subject: [PATCH 01/28] fix template creation --- packages/workers/src/lib/mock-templates.ts | 35 +++++++++++----------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/packages/workers/src/lib/mock-templates.ts b/packages/workers/src/lib/mock-templates.ts index 8d9341d7..d0a3a401 100644 --- a/packages/workers/src/lib/mock-templates.ts +++ b/packages/workers/src/lib/mock-templates.ts @@ -53,7 +53,7 @@ interface AMSTAR2Options { interface AMSTAR2Answers { [questionKey: string]: { - answers: boolean[][][]; + answers: boolean[][]; critical: boolean; }; } @@ -64,24 +64,23 @@ export function generateAMSTAR2Answers(options: AMSTAR2Options = {}): AMSTAR2Ans const answers: AMSTAR2Answers = {}; for (const [questionKey, structure] of Object.entries(AMSTAR2_STRUCTURE)) { - const questionAnswers = structure.parts.map(partSizes => - partSizes.map(size => { - const row = new Array(size).fill(false); - if (fill === 'random') { - for (let i = 0; i < size; i++) { - row[i] = rng() > 0.5; - } - } else if (fill === 'all-yes') { - row[0] = true; - } else if (fill === 'all-no') { - row[row.length - 1] = true; - } else if (fill === 'mixed') { - const idx = Math.floor(rng() * size); - row[idx] = true; + const questionAnswers = structure.parts.map(partSizes => { + const size = partSizes[0]; + const row = new Array(size).fill(false); + if (fill === 'random') { + for (let i = 0; i < size; i++) { + row[i] = rng() > 0.5; } - return row; - }), - ); + } else if (fill === 'all-yes') { + row[0] = true; + } else if (fill === 'all-no') { + row[row.length - 1] = true; + } else if (fill === 'mixed') { + const idx = Math.floor(rng() * size); + row[idx] = true; + } + return row; + }); answers[questionKey] = { answers: questionAnswers, From db79d0091353864daf11b366a24668c2e258e7bd Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 1 May 2026 19:38:09 -0500 Subject: [PATCH 02/28] Convert to more granular checklist render approach --- .../checklist/ChecklistYjsWrapper.tsx | 14 +- .../useProject/checklists/handlers/amstar2.ts | 9 - .../useProject/checklists/handlers/base.ts | 14 +- .../useProject/checklists/handlers/rob2.ts | 116 +++++----- .../checklists/handlers/robins-i.ts | 127 +++++------ .../checklists/useChecklistAnswers.ts | 87 ++++++-- .../src/primitives/useProject/sync-perf.ts | 60 ++++++ .../web/src/primitives/useProject/sync.ts | 201 ++++++------------ packages/web/src/stores/projectAtoms.ts | 39 +++- packages/web/src/stores/projectStore.ts | 11 +- 10 files changed, 375 insertions(+), 303 deletions(-) create mode 100644 packages/web/src/primitives/useProject/sync-perf.ts diff --git a/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx b/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx index 82414e29..e258c8ce 100644 --- a/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx +++ b/packages/web/src/components/checklist/ChecklistYjsWrapper.tsx @@ -196,13 +196,17 @@ export function ChecklistYjsWrapper({ projectId, studyId, checklistId }: Checkli const handlePartialUpdate = useCallback( (patch: Record) => { if (isReadOnly || !checklistType) return; - Object.entries(patch).forEach(([key, value]) => { - const input = buildChecklistAnswerInput(checklistType, key, value); - if (!input) return; - updateChecklistAnswer(studyId, checklistId, input); + const ydoc = connectionPool.getEntry(projectId)?.ydoc; + if (!ydoc) return; + ydoc.transact(() => { + Object.entries(patch).forEach(([key, value]) => { + const input = buildChecklistAnswerInput(checklistType, key, value); + if (!input) return; + updateChecklistAnswer(studyId, checklistId, input); + }); }); }, - [isReadOnly, checklistType, updateChecklistAnswer, studyId, checklistId], + [isReadOnly, checklistType, updateChecklistAnswer, studyId, checklistId, projectId], ); const handleToggleComplete = useCallback(() => { diff --git a/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts b/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts index 2802a80e..bc85fd7c 100644 --- a/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts +++ b/packages/web/src/primitives/useProject/checklists/handlers/amstar2.ts @@ -49,15 +49,6 @@ export class AMSTAR2Handler extends ChecklistHandler { return answersYMap; } - serializeAnswers(answersMap: Y.Map): Record { - const answers: Record = {}; - for (const [key, sectionYMap] of answersMap.entries()) { - const section = sectionYMap as { toJSON?: () => unknown }; - answers[key] = section.toJSON ? section.toJSON() : sectionYMap; - } - return answers; - } - updateAnswer( answersMap: Y.Map, key: K, diff --git a/packages/web/src/primitives/useProject/checklists/handlers/base.ts b/packages/web/src/primitives/useProject/checklists/handlers/base.ts index 6d7c8fa0..b82386f9 100644 --- a/packages/web/src/primitives/useProject/checklists/handlers/base.ts +++ b/packages/web/src/primitives/useProject/checklists/handlers/base.ts @@ -16,7 +16,19 @@ export type TextGetterFn = ( export abstract class ChecklistHandler { abstract extractAnswersFromTemplate(template: Record): Record; abstract createAnswersYMap(answersData: Record): Y.Map; - abstract serializeAnswers(answersMap: Y.Map): Record; + + serializeKey(_key: string, sectionYMap: unknown): unknown { + const section = sectionYMap as { toJSON?: () => unknown }; + return section.toJSON ? section.toJSON() : sectionYMap; + } + + serializeAnswers(answersMap: Y.Map): Record { + const answers: Record = {}; + for (const [key, sectionYMap] of answersMap.entries()) { + answers[key] = this.serializeKey(key, sectionYMap); + } + return answers; + } getTextGetter(_getYDoc: () => Y.Doc | null): TextGetterFn | null { return null; diff --git a/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts b/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts index e6b28ca5..4624287a 100644 --- a/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts +++ b/packages/web/src/primitives/useProject/checklists/handlers/rob2.ts @@ -89,75 +89,67 @@ export class ROB2Handler extends ChecklistHandler { return answersYMap; } - serializeAnswers(answersMap: Y.Map): Record { - const answers: Record = {}; - for (const [key, sectionYMap] of answersMap.entries()) { - if (!(sectionYMap instanceof Y.Map)) { - answers[key] = sectionYMap; - continue; - } + serializeKey(key: string, sectionYMap: unknown): unknown { + if (!(sectionYMap instanceof Y.Map)) return sectionYMap; - if (key.startsWith('domain')) { - const sectionData: Record = { - judgement: sectionYMap.get('judgement') ?? null, - answers: {} as Record, - }; - const direction = sectionYMap.get('direction'); - if (direction !== undefined) { - sectionData.direction = direction; - } + if (key.startsWith('domain')) { + const sectionData: Record = { + judgement: sectionYMap.get('judgement') ?? null, + answers: {} as Record, + }; + const direction = sectionYMap.get('direction'); + if (direction !== undefined) sectionData.direction = direction; - const answersNestedYMap = sectionYMap.get('answers'); - if (answersNestedYMap instanceof Y.Map) { - const answersObj = sectionData.answers as Record< - string, - { answer: string | null; comment: string } - >; - for (const [qKey, questionYMap] of answersNestedYMap.entries()) { - if (questionYMap instanceof Y.Map) { - const commentValue = questionYMap.get('comment'); - answersObj[qKey] = { - answer: (questionYMap.get('answer') as string) ?? null, - comment: yTextToString(commentValue), - }; - } else { - answersObj[qKey] = questionYMap as { answer: string | null; comment: string }; - } - } - } - answers[key] = sectionData; - } else if (key === 'overall') { - const sectionData: Record = { - judgement: sectionYMap.get('judgement') ?? null, - }; - const direction = sectionYMap.get('direction'); - if (direction !== undefined) { - sectionData.direction = direction; - } - answers[key] = sectionData; - } else if (key === 'preliminary') { - answers[key] = { - studyDesign: sectionYMap.get('studyDesign') ?? null, - experimental: yTextToString(sectionYMap.get('experimental')), - comparator: yTextToString(sectionYMap.get('comparator')), - numericalResult: yTextToString(sectionYMap.get('numericalResult')), - aim: sectionYMap.get('aim') ?? null, - deviationsToAddress: sectionYMap.get('deviationsToAddress') ?? [], - sources: sectionYMap.get('sources') ?? {}, - }; - } else { - const sectionData: Record = {}; - for (const [fieldKey, fieldValue] of sectionYMap.entries()) { - if (fieldValue instanceof Y.Text) { - sectionData[fieldKey] = fieldValue.toString(); + const answersNestedYMap = sectionYMap.get('answers'); + if (answersNestedYMap instanceof Y.Map) { + const answersObj = sectionData.answers as Record< + string, + { answer: string | null; comment: string } + >; + for (const [qKey, questionYMap] of answersNestedYMap.entries()) { + if (questionYMap instanceof Y.Map) { + answersObj[qKey] = { + answer: (questionYMap.get('answer') as string) ?? null, + comment: yTextToString(questionYMap.get('comment')), + }; } else { - sectionData[fieldKey] = fieldValue; + answersObj[qKey] = questionYMap as { answer: string | null; comment: string }; } } - answers[key] = sectionData; + } + return sectionData; + } + + if (key === 'overall') { + const sectionData: Record = { + judgement: sectionYMap.get('judgement') ?? null, + }; + const direction = sectionYMap.get('direction'); + if (direction !== undefined) sectionData.direction = direction; + return sectionData; + } + + if (key === 'preliminary') { + return { + studyDesign: sectionYMap.get('studyDesign') ?? null, + experimental: yTextToString(sectionYMap.get('experimental')), + comparator: yTextToString(sectionYMap.get('comparator')), + numericalResult: yTextToString(sectionYMap.get('numericalResult')), + aim: sectionYMap.get('aim') ?? null, + deviationsToAddress: sectionYMap.get('deviationsToAddress') ?? [], + sources: sectionYMap.get('sources') ?? {}, + }; + } + + const sectionData: Record = {}; + for (const [fieldKey, fieldValue] of sectionYMap.entries()) { + if (fieldValue instanceof Y.Text) { + sectionData[fieldKey] = fieldValue.toString(); + } else { + sectionData[fieldKey] = fieldValue; } } - return answers; + return sectionData; } updateAnswer(answersMap: Y.Map, key: K, data: Rob2Answers[K]): void { diff --git a/packages/web/src/primitives/useProject/checklists/handlers/robins-i.ts b/packages/web/src/primitives/useProject/checklists/handlers/robins-i.ts index 1285c257..e0c02df0 100644 --- a/packages/web/src/primitives/useProject/checklists/handlers/robins-i.ts +++ b/packages/web/src/primitives/useProject/checklists/handlers/robins-i.ts @@ -109,81 +109,72 @@ export class ROBINSIHandler extends ChecklistHandler { return answersYMap; } - serializeAnswers(answersMap: Y.Map): Record { - const answers: Record = {}; - for (const [key, sectionYMap] of answersMap.entries()) { - if (!(sectionYMap instanceof Y.Map)) { - answers[key] = sectionYMap; - continue; - } - - if (key.startsWith('domain')) { - const sectionData: Record = { - judgement: sectionYMap.get('judgement') ?? null, - judgementSource: sectionYMap.get('judgementSource') ?? 'auto', - answers: {} as Record, - }; - const direction = sectionYMap.get('direction'); - if (direction !== undefined) { - sectionData.direction = direction; - } - - const answersNestedYMap = sectionYMap.get('answers'); - if (answersNestedYMap instanceof Y.Map) { - const answersObj = sectionData.answers as Record< - string, - { answer: string | null; comment: string } - >; - for (const [qKey, questionYMap] of answersNestedYMap.entries()) { - if (questionYMap instanceof Y.Map) { - const commentValue = questionYMap.get('comment'); - answersObj[qKey] = { - answer: (questionYMap.get('answer') as string) ?? null, - comment: yTextToString(commentValue), - }; - } else { - answersObj[qKey] = questionYMap as { answer: string | null; comment: string }; - } - } - } - answers[key] = sectionData; - } else if (key === 'overall') { - const sectionData: Record = { - judgement: sectionYMap.get('judgement') ?? null, - judgementSource: sectionYMap.get('judgementSource') ?? 'auto', - }; - const direction = sectionYMap.get('direction'); - if (direction !== undefined) { - sectionData.direction = direction; - } - answers[key] = sectionData; - } else if (key === 'sectionB') { - const sectionData: Record = {}; - for (const [subKey, subValue] of sectionYMap.entries()) { - if (subValue instanceof Y.Map) { - const commentValue = subValue.get('comment'); - sectionData[subKey] = { - answer: subValue.get('answer') ?? null, - comment: yTextToString(commentValue), + serializeKey(key: string, sectionYMap: unknown): unknown { + if (!(sectionYMap instanceof Y.Map)) return sectionYMap; + + if (key.startsWith('domain')) { + const sectionData: Record = { + judgement: sectionYMap.get('judgement') ?? null, + judgementSource: sectionYMap.get('judgementSource') ?? 'auto', + answers: {} as Record, + }; + const direction = sectionYMap.get('direction'); + if (direction !== undefined) sectionData.direction = direction; + + const answersNestedYMap = sectionYMap.get('answers'); + if (answersNestedYMap instanceof Y.Map) { + const answersObj = sectionData.answers as Record< + string, + { answer: string | null; comment: string } + >; + for (const [qKey, questionYMap] of answersNestedYMap.entries()) { + if (questionYMap instanceof Y.Map) { + answersObj[qKey] = { + answer: (questionYMap.get('answer') as string) ?? null, + comment: yTextToString(questionYMap.get('comment')), }; } else { - sectionData[subKey] = subValue; + answersObj[qKey] = questionYMap as { answer: string | null; comment: string }; } } - answers[key] = sectionData; - } else { - const sectionData: Record = {}; - for (const [fieldKey, fieldValue] of sectionYMap.entries()) { - if (fieldValue instanceof Y.Text) { - sectionData[fieldKey] = fieldValue.toString(); - } else { - sectionData[fieldKey] = fieldValue; - } + } + return sectionData; + } + + if (key === 'overall') { + const sectionData: Record = { + judgement: sectionYMap.get('judgement') ?? null, + judgementSource: sectionYMap.get('judgementSource') ?? 'auto', + }; + const direction = sectionYMap.get('direction'); + if (direction !== undefined) sectionData.direction = direction; + return sectionData; + } + + if (key === 'sectionB') { + const sectionData: Record = {}; + for (const [subKey, subValue] of sectionYMap.entries()) { + if (subValue instanceof Y.Map) { + sectionData[subKey] = { + answer: subValue.get('answer') ?? null, + comment: yTextToString(subValue.get('comment')), + }; + } else { + sectionData[subKey] = subValue; } - answers[key] = sectionData; + } + return sectionData; + } + + const sectionData: Record = {}; + for (const [fieldKey, fieldValue] of sectionYMap.entries()) { + if (fieldValue instanceof Y.Text) { + sectionData[fieldKey] = fieldValue.toString(); + } else { + sectionData[fieldKey] = fieldValue; } } - return answers; + return sectionData; } updateAnswer( diff --git a/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts b/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts index 8337d28d..0fc7b202 100644 --- a/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts +++ b/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts @@ -1,10 +1,10 @@ /** * Subscription-based read of a checklist's answers from the project Y.Doc. * - * Replaces the useMemo+Zustand indirection in ChecklistYjsWrapper. The hook - * observes the project's `reviews` map and serializes the requested checklist's - * answers on demand. The returned reference is stable between Y.Doc updates, so - * consumers only re-render when answers actually change. + * Observes the narrowest possible Yjs target: the checklist's answers Y.Map + * when it exists, or the checklist Y.Map (shallow) while waiting for answers + * to be created. This prevents unrelated Yjs mutations (annotations, other + * studies, PDF metadata) from triggering re-serialization. */ import { useCallback, useRef, useSyncExternalStore } from 'react'; @@ -13,6 +13,7 @@ import { connectionPool } from '@/project/ConnectionPool'; import { CHECKLIST_TYPES } from '@/checklist-registry'; import type { ChecklistHandler } from './handlers/base'; import { AMSTAR2Handler } from './handlers/amstar2'; +import { countProbe } from '../sync-perf'; import { ROBINSIHandler } from './handlers/robins-i'; import { ROB2Handler } from './handlers/rob2'; @@ -27,18 +28,26 @@ interface ResolvedAnswers { checklistType: string; } -function resolveAnswers( +function resolveChecklistYMap( ydoc: Y.Doc | null, studyId: string, checklistId: string, -): ResolvedAnswers | null { +): Y.Map | null { if (!ydoc) return null; const studiesMap = ydoc.getMap('reviews'); const studyYMap = studiesMap.get(studyId) as Y.Map | undefined; if (!studyYMap) return null; const checklistsMap = studyYMap.get('checklists') as Y.Map | undefined; if (!checklistsMap) return null; - const checklistYMap = checklistsMap.get(checklistId) as Y.Map | undefined; + return (checklistsMap.get(checklistId) as Y.Map) ?? null; +} + +function resolveAnswers( + ydoc: Y.Doc | null, + studyId: string, + checklistId: string, +): ResolvedAnswers | null { + const checklistYMap = resolveChecklistYMap(ydoc, studyId, checklistId); if (!checklistYMap) return null; const answersYMap = checklistYMap.get('answers') as Y.Map | undefined; if (!answersYMap) return null; @@ -46,15 +55,17 @@ function resolveAnswers( return { answersYMap, checklistType }; } -function serialize(resolved: ResolvedAnswers): Record { - const handler = handlers[resolved.checklistType]; - if (handler) return handler.serializeAnswers(resolved.answersYMap); - const fallback: Record = {}; - for (const [key, section] of resolved.answersYMap.entries()) { - const s = section as { toJSON?: () => unknown }; - fallback[key] = s.toJSON ? s.toJSON() : section; +function stabilizeRefs( + fresh: Record, + prev: Record | null, +): Record { + if (!prev) return fresh; + for (const key of Object.keys(fresh)) { + if (key in prev && JSON.stringify(fresh[key]) === JSON.stringify(prev[key])) { + fresh[key] = prev[key]; + } } - return fallback; + return fresh; } export function useChecklistAnswers( @@ -75,18 +86,27 @@ export function useChecklistAnswers( const subscribe = useCallback( (onStoreChange: () => void) => { if (!ydoc) return () => {}; - // observeDeep on `reviews` catches both "checklist arrives via sync" - // and "answers inside this checklist change". Serialization happens in - // getSnapshot, so unrelated writes only bump a counter here. - const reviewsMap = ydoc.getMap('reviews'); + const observer = () => { versionRef.current += 1; onStoreChange(); }; - reviewsMap.observeDeep(observer); - return () => reviewsMap.unobserveDeep(observer); + + const resolved = resolveAnswers(ydoc, studyId, checklistId); + if (resolved) { + resolved.answersYMap.observeDeep(observer); + return () => resolved.answersYMap.unobserveDeep(observer); + } + + const checklistYMap = resolveChecklistYMap(ydoc, studyId, checklistId); + if (checklistYMap) { + checklistYMap.observe(observer); + return () => checklistYMap.unobserve(observer); + } + + return () => {}; }, - [ydoc], + [ydoc, studyId, checklistId], ); const getSnapshot = useCallback((): Record | null => { @@ -96,14 +116,35 @@ export function useChecklistAnswers( cached.studyId === studyId && cached.checklistId === checklistId ) { + countProbe('serializeCacheHit'); return cached.value; } + countProbe('serialize'); const resolved = resolveAnswers(ydoc, studyId, checklistId); - const value = resolved ? serialize(resolved) : null; + if (!resolved) { + cacheRef.current = { version: versionRef.current, studyId, checklistId, value: null }; + return null; + } + + const handler = handlers[resolved.checklistType]; + const fresh = handler + ? handler.serializeAnswers(resolved.answersYMap) + : fallbackSerialize(resolved.answersYMap); + + const value = stabilizeRefs(fresh, cached.value); cacheRef.current = { version: versionRef.current, studyId, checklistId, value }; return value; }, [ydoc, studyId, checklistId]); return useSyncExternalStore(subscribe, getSnapshot); } + +function fallbackSerialize(answersMap: Y.Map): Record { + const result: Record = {}; + for (const [key, section] of answersMap.entries()) { + const s = section as { toJSON?: () => unknown }; + result[key] = s.toJSON ? s.toJSON() : section; + } + return result; +} diff --git a/packages/web/src/primitives/useProject/sync-perf.ts b/packages/web/src/primitives/useProject/sync-perf.ts new file mode 100644 index 00000000..dd2a694d --- /dev/null +++ b/packages/web/src/primitives/useProject/sync-perf.ts @@ -0,0 +1,60 @@ +const ENABLED = import.meta.env.DEV; + +let cycleStart = 0; +const counts: Record = { + handleReviewsEvents: 0, + buildStudy: 0, + serialize: 0, + serializeCacheHit: 0, + doSync: 0, + atomFired: 0, + atomSuppressed: 0, +}; +let buildStudyMs = 0; + +let flushScheduled = false; + +function flush(): void { + const elapsed = performance.now() - cycleStart; + const parts = [ + `handleReviewsEvents=${counts.handleReviewsEvents}`, + `buildStudy=${counts.buildStudy}(${buildStudyMs.toFixed(1)}ms)`, + `serialize=${counts.serialize}(hit=${counts.serializeCacheHit})`, + `doSync=${counts.doSync}`, + `atomFired=${counts.atomFired}`, + `atomSuppressed=${counts.atomSuppressed}`, + ]; + console.log(`[perf] edit cycle: ${parts.join(' ')} (${elapsed.toFixed(1)}ms)`); + + for (const key of Object.keys(counts)) { + counts[key] = 0; + } + buildStudyMs = 0; + flushScheduled = false; +} + +function scheduleFlush(): void { + if (flushScheduled) return; + flushScheduled = true; + requestAnimationFrame(() => { + requestAnimationFrame(flush); + }); +} + +export function markCycleStart(): void { + if (!ENABLED) return; + if (!flushScheduled) { + cycleStart = performance.now(); + } + scheduleFlush(); +} + +export function countProbe(name: keyof typeof counts): void { + if (!ENABLED) return; + counts[name] += 1; +} + +export function addBuildStudyTime(ms: number): void { + if (!ENABLED) return; + buildStudyMs += ms; +} diff --git a/packages/web/src/primitives/useProject/sync.ts b/packages/web/src/primitives/useProject/sync.ts index e5155639..e73b2306 100644 --- a/packages/web/src/primitives/useProject/sync.ts +++ b/packages/web/src/primitives/useProject/sync.ts @@ -7,7 +7,6 @@ import * as Y from 'yjs'; import type { StudyInfo, ChecklistEntry, - AnnotationEntry, MemberEntry, ProjectMeta, OutcomeEntry, @@ -18,6 +17,7 @@ import { scoreChecklistOfType } from '@/checklist-registry/index'; import { amstar2 } from '@corates/shared'; import { CHECKLIST_STATUS } from '@corates/shared/checklists'; import type { AMSTAR2Checklist } from '@corates/shared/checklists'; +import { markCycleStart, countProbe, addBuildStudyTime } from './sync-perf'; const getAMSTAR2Answers = amstar2.getAnswers; @@ -45,6 +45,8 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null function handleReviewsEvents(events: Y.YEvent[]): void { if (paused) return; + markCycleStart(); + countProbe('handleReviewsEvents'); const reviewsMap = getYDoc()?.getMap('reviews'); if (!reviewsMap) return; @@ -69,11 +71,10 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null for (const studyId of dirtyStudyIds) { const studyYMap = reviewsMap.get(studyId) as Y.Map | undefined; if (studyYMap) { - const studyData = studyYMap.toJSON ? studyYMap.toJSON() : {}; - studyCache.set( - studyId, - buildStudyFromYMap(studyId, studyData as Record, studyYMap), - ); + countProbe('buildStudy'); + const t0 = performance.now(); + studyCache.set(studyId, buildStudyFromYMap(studyId, studyYMap)); + addBuildStudyTime(performance.now() - t0); } else { studyCache.delete(studyId); } @@ -106,11 +107,7 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null for (const [studyId, studyYMap] of reviewsMap.entries()) { const ymap = studyYMap as Y.Map; - const studyData = ymap.toJSON ? ymap.toJSON() : {}; - studyCache.set( - studyId, - buildStudyFromYMap(studyId, studyData as Record, ymap), - ); + studyCache.set(studyId, buildStudyFromYMap(studyId, ymap)); } sortedStudies = [...studyCache.values()].sort( @@ -140,6 +137,7 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null } function doSync(): void { + countProbe('doSync'); const ydoc = getYDoc(); if (!ydoc) return; @@ -265,68 +263,63 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null return { syncFromYDocImmediate, attach, detach, pause, resume }; } -function buildStudyFromYMap( - studyId: string, - studyData: Record, - studyYMap: Y.Map, -): StudyInfo { +function getStr(m: Y.Map, key: string): string | null { + return (m.get(key) as string) || null; +} + +function buildStudyFromYMap(studyId: string, studyYMap: Y.Map): StudyInfo { const study: StudyInfo = { id: studyId, - name: (studyData.name as string) || '', - description: (studyData.description as string) || '', - originalTitle: (studyData.originalTitle as string) || null, - firstAuthor: (studyData.firstAuthor as string) || null, - publicationYear: (studyData.publicationYear as string) || null, - authors: (studyData.authors as string) || null, - journal: (studyData.journal as string) || null, - doi: (studyData.doi as string) || null, - abstract: (studyData.abstract as string) || null, - importSource: (studyData.importSource as string) || null, - pdfUrl: (studyData.pdfUrl as string) || null, - pdfSource: (studyData.pdfSource as string) || null, - pdfAccessible: Boolean(studyData.pdfAccessible || false), - pmid: (studyData.pmid as string) || null, - url: (studyData.url as string) || null, - volume: (studyData.volume as string) || null, - issue: (studyData.issue as string) || null, - pages: (studyData.pages as string) || null, - type: (studyData.type as string) || null, - reviewer1: (studyData.reviewer1 as string) || null, - reviewer2: (studyData.reviewer2 as string) || null, - createdAt: studyData.createdAt as number, - updatedAt: studyData.updatedAt as number, + name: (studyYMap.get('name') as string) || '', + description: (studyYMap.get('description') as string) || '', + originalTitle: getStr(studyYMap, 'originalTitle'), + firstAuthor: getStr(studyYMap, 'firstAuthor'), + publicationYear: getStr(studyYMap, 'publicationYear'), + authors: getStr(studyYMap, 'authors'), + journal: getStr(studyYMap, 'journal'), + doi: getStr(studyYMap, 'doi'), + abstract: getStr(studyYMap, 'abstract'), + importSource: getStr(studyYMap, 'importSource'), + pdfUrl: getStr(studyYMap, 'pdfUrl'), + pdfSource: getStr(studyYMap, 'pdfSource'), + pdfAccessible: Boolean(studyYMap.get('pdfAccessible') || false), + pmid: getStr(studyYMap, 'pmid'), + url: getStr(studyYMap, 'url'), + volume: getStr(studyYMap, 'volume'), + issue: getStr(studyYMap, 'issue'), + pages: getStr(studyYMap, 'pages'), + type: getStr(studyYMap, 'type'), + reviewer1: getStr(studyYMap, 'reviewer1'), + reviewer2: getStr(studyYMap, 'reviewer2'), + createdAt: studyYMap.get('createdAt') as number, + updatedAt: studyYMap.get('updatedAt') as number, checklists: [], pdfs: [], - annotations: {}, }; // Checklists const checklistsMap = studyYMap.get('checklists') as Y.Map | undefined; if (checklistsMap && typeof checklistsMap.entries === 'function') { for (const [checklistId, checklistYMap] of checklistsMap.entries()) { - const clYMap = checklistYMap as Y.Map & { toJSON?: () => Record }; - const checklistData = - clYMap.toJSON ? clYMap.toJSON() : (checklistYMap as Record); - const checklistType = (checklistData.type as string) || 'AMSTAR2'; - const status = (checklistData.status as string) || 'pending'; + const clYMap = checklistYMap as Y.Map; + const checklistType = (clYMap.get('type') as string) || 'AMSTAR2'; + const status = (clYMap.get('status') as string) || 'pending'; const checklistEntry: ChecklistEntry = { id: checklistId, type: checklistType, - title: (checklistData.title as string) || null, - assignedTo: (checklistData.assignedTo as string) || null, - outcomeId: (checklistData.outcomeId as string) || null, + title: getStr(clYMap, 'title'), + assignedTo: getStr(clYMap, 'assignedTo'), + outcomeId: getStr(clYMap, 'outcomeId'), status, - createdAt: checklistData.createdAt as number, - updatedAt: checklistData.updatedAt as number, + createdAt: clYMap.get('createdAt') as number, + updatedAt: clYMap.get('updatedAt') as number, score: null, answers: null, }; if (status === CHECKLIST_STATUS.FINALIZED) { - const answersMap = (checklistYMap as Y.Map).get('answers') as - | Y.Map - | undefined; + const answersMap = clYMap.get('answers') as Y.Map | undefined; if (answersMap && typeof answersMap.entries === 'function') { const answers = extractAnswersFromYMap(answersMap, checklistType); checklistEntry.answers = answers; @@ -352,98 +345,44 @@ function buildStudyFromYMap( const pdfsMap = studyYMap.get('pdfs') as Y.Map | undefined; if (pdfsMap && typeof pdfsMap.entries === 'function') { for (const [pdfId, pdfYMap] of pdfsMap.entries()) { - const pYMap = pdfYMap as { toJSON?: () => Record }; - const pdfData = pYMap.toJSON ? pYMap.toJSON() : (pdfYMap as Record); + const pYMap = pdfYMap as Y.Map; study.pdfs.push({ - id: (pdfData.id as string) || pdfId, - fileName: (pdfData.fileName as string) || pdfId, - key: pdfData.key as string, - size: pdfData.size as number, - uploadedBy: pdfData.uploadedBy as string, - uploadedAt: pdfData.uploadedAt as number, - tag: (pdfData.tag as string) || 'secondary', - title: (pdfData.title as string) || null, - firstAuthor: (pdfData.firstAuthor as string) || null, - publicationYear: (pdfData.publicationYear as string) || null, - journal: (pdfData.journal as string) || null, - doi: (pdfData.doi as string) || null, + id: (pYMap.get('id') as string) || pdfId, + fileName: (pYMap.get('fileName') as string) || pdfId, + key: pYMap.get('key') as string, + size: pYMap.get('size') as number, + uploadedBy: pYMap.get('uploadedBy') as string, + uploadedAt: pYMap.get('uploadedAt') as number, + tag: (pYMap.get('tag') as string) || 'secondary', + title: getStr(pYMap, 'title'), + firstAuthor: getStr(pYMap, 'firstAuthor'), + publicationYear: getStr(pYMap, 'publicationYear'), + journal: getStr(pYMap, 'journal'), + doi: getStr(pYMap, 'doi'), }); } } - // Reconciliation (legacy format) - const reconciliationMap = studyYMap.get('reconciliation') as - | (Y.Map & { toJSON?: () => Record }) - | undefined; + // Reconciliation + const reconciliationMap = studyYMap.get('reconciliation') as Y.Map | undefined; if (reconciliationMap) { - const reconciliationData = - reconciliationMap.toJSON ? - reconciliationMap.toJSON() - : (reconciliationMap as unknown as Record); - if (reconciliationData.checklist1Id && reconciliationData.checklist2Id) { + const c1 = reconciliationMap.get('checklist1Id') as string | undefined; + const c2 = reconciliationMap.get('checklist2Id') as string | undefined; + if (c1 && c2) { study.reconciliation = { - checklist1Id: reconciliationData.checklist1Id as string, - checklist2Id: reconciliationData.checklist2Id as string, - reconciledChecklistId: (reconciliationData.reconciledChecklistId as string) || null, - currentPage: (reconciliationData.currentPage as number) || 0, - viewMode: (reconciliationData.viewMode as string) || 'questions', - updatedAt: reconciliationData.updatedAt as number, + checklist1Id: c1, + checklist2Id: c2, + reconciledChecklistId: getStr(reconciliationMap, 'reconciledChecklistId'), + currentPage: (reconciliationMap.get('currentPage') as number) || 0, + viewMode: (reconciliationMap.get('viewMode') as string) || 'questions', + updatedAt: reconciliationMap.get('updatedAt') as number, }; } } - // Annotations - const annotationsMap = studyYMap.get('annotations') as Y.Map | undefined; - if (annotationsMap && typeof annotationsMap.entries === 'function') { - study.annotations = buildAnnotationsFromYMap(annotationsMap); - } - return study; } -function buildAnnotationsFromYMap( - annotationsMap: Y.Map, -): Record { - const annotations: Record = {}; - for (const [checklistId, checklistAnnotationsMap] of annotationsMap.entries()) { - const clMap = checklistAnnotationsMap as Y.Map | undefined; - if (!clMap || typeof clMap.entries !== 'function') continue; - - const checklistAnnotations: AnnotationEntry[] = []; - for (const [annotationId, annotationYMap] of clMap.entries()) { - if (!annotationYMap) continue; - - const aYMap = annotationYMap as { toJSON?: () => Record }; - const annotationData = - aYMap.toJSON ? aYMap.toJSON() : (annotationYMap as Record); - - let embedPdfData: Record = {}; - try { - embedPdfData = JSON.parse((annotationData.embedPdfData as string) || '{}'); - } catch (err) { - console.warn('Failed to parse annotation embedPdfData:', annotationId, err); - } - - checklistAnnotations.push({ - id: (annotationData.id as string) || annotationId, - pdfId: annotationData.pdfId as string, - type: annotationData.type as string, - pageIndex: annotationData.pageIndex as number, - embedPdfData, - createdBy: annotationData.createdBy as string, - createdAt: annotationData.createdAt as number, - updatedAt: annotationData.updatedAt as number, - mergedFrom: (annotationData.mergedFrom as string) || null, - }); - } - - if (checklistAnnotations.length > 0) { - annotations[checklistId] = checklistAnnotations; - } - } - return annotations; -} - function buildMembersList(membersMap: Y.Map): MemberEntry[] { const membersList: MemberEntry[] = []; for (const [userId, memberYMap] of membersMap.entries()) { diff --git a/packages/web/src/stores/projectAtoms.ts b/packages/web/src/stores/projectAtoms.ts index c38d8754..e7d164e8 100644 --- a/packages/web/src/stores/projectAtoms.ts +++ b/packages/web/src/stores/projectAtoms.ts @@ -2,6 +2,7 @@ import { atom, transact } from '@tldraw/state'; import type { Atom } from '@tldraw/state'; import { useValue } from '@tldraw/state-react'; import type { StudyInfo, MemberEntry, ProjectMeta } from './projectStore'; +import { countProbe } from '@/primitives/useProject/sync-perf'; function arraysEqual(a: string[], b: string[]): boolean { if (a.length !== b.length) return false; @@ -11,6 +12,31 @@ function arraysEqual(a: string[], b: string[]): boolean { return true; } +function studyEquals(a: StudyInfo | undefined, b: StudyInfo | undefined): boolean { + if (a === b) return true; + if (!a || !b) return false; + if (a.name !== b.name || a.description !== b.description) return false; + if (a.reviewer1 !== b.reviewer1 || a.reviewer2 !== b.reviewer2) return false; + if (a.firstAuthor !== b.firstAuthor || a.publicationYear !== b.publicationYear) return false; + if (a.doi !== b.doi || a.abstract !== b.abstract) return false; + if (a.pdfAccessible !== b.pdfAccessible) return false; + if (a.pdfs.length !== b.pdfs.length) return false; + if (a.checklists.length !== b.checklists.length) return false; + for (let i = 0; i < a.checklists.length; i++) { + const ca = a.checklists[i]; + const cb = b.checklists[i]; + if ( + ca.id !== cb.id || + ca.status !== cb.status || + ca.score !== cb.score || + ca.assignedTo !== cb.assignedTo || + ca.outcomeId !== cb.outcomeId + ) + return false; + } + return true; +} + class ProjectAtoms { private studyAtoms = new Map>(); readonly studyOrder = atom('studyOrder', [], { isEqual: arraysEqual }); @@ -20,14 +46,23 @@ class ProjectAtoms { getOrCreateStudyAtom(studyId: string): Atom { let a = this.studyAtoms.get(studyId); if (!a) { - a = atom(`study:${studyId}`, undefined); + a = atom(`study:${studyId}`, undefined, { + isEqual: studyEquals, + }); this.studyAtoms.set(studyId, a); } return a; } setStudy(studyId: string, study: StudyInfo): void { - this.getOrCreateStudyAtom(studyId).set(study); + const a = this.getOrCreateStudyAtom(studyId); + const prev = a.__unsafe__getWithoutCapture(); + a.set(study); + if (a.__unsafe__getWithoutCapture() !== prev) { + countProbe('atomFired'); + } else { + countProbe('atomSuppressed'); + } } deleteStudy(studyId: string): void { diff --git a/packages/web/src/stores/projectStore.ts b/packages/web/src/stores/projectStore.ts index 6415b810..9b59b803 100644 --- a/packages/web/src/stores/projectStore.ts +++ b/packages/web/src/stores/projectStore.ts @@ -129,7 +129,6 @@ export interface StudyInfo { checklists: ChecklistEntry[]; pdfs: PdfEntry[]; reconciliation?: ReconciliationEntry; - annotations: Record; } interface ProjectStoreState { @@ -188,8 +187,16 @@ export const useProjectStore = create() }), updateProjectStats: (projectId, studies) => { + const stats = computeProjectStats(studies); + const current = useProjectStore.getState().projectStats[projectId]; + if ( + current && + current.studyCount === stats.studyCount && + current.completedCount === stats.completedCount + ) { + return; + } set(state => { - const stats = computeProjectStats(studies); state.projectStats[projectId] = { ...stats, lastUpdated: Date.now() }; }); persistStats(useProjectStore.getState().projectStats); From d906419abd916905e65f184ed465f3725df8cfd6 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Fri, 1 May 2026 19:54:48 -0500 Subject: [PATCH 03/28] refactor and clean up performance improvements for checklist edits --- .../checklists/handlers/registry.ts | 14 ++ .../checklists/useChecklistAnswers.ts | 14 +- .../src/primitives/useProject/sync-perf.ts | 3 +- .../web/src/primitives/useProject/sync.ts | 174 ++++-------------- 4 files changed, 57 insertions(+), 148 deletions(-) create mode 100644 packages/web/src/primitives/useProject/checklists/handlers/registry.ts diff --git a/packages/web/src/primitives/useProject/checklists/handlers/registry.ts b/packages/web/src/primitives/useProject/checklists/handlers/registry.ts new file mode 100644 index 00000000..6396760b --- /dev/null +++ b/packages/web/src/primitives/useProject/checklists/handlers/registry.ts @@ -0,0 +1,14 @@ +import type { ChecklistHandler } from './base'; +import { AMSTAR2Handler } from './amstar2'; +import { ROB2Handler } from './rob2'; +import { ROBINSIHandler } from './robins-i'; + +const handlers: Record = { + AMSTAR2: new AMSTAR2Handler(), + ROB2: new ROB2Handler(), + ROBINS_I: new ROBINSIHandler(), +}; + +export function getHandler(checklistType: string): ChecklistHandler | null { + return handlers[checklistType] ?? null; +} diff --git a/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts b/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts index 0fc7b202..4e2c56aa 100644 --- a/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts +++ b/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts @@ -10,18 +10,8 @@ import { useCallback, useRef, useSyncExternalStore } from 'react'; import * as Y from 'yjs'; import { connectionPool } from '@/project/ConnectionPool'; -import { CHECKLIST_TYPES } from '@/checklist-registry'; -import type { ChecklistHandler } from './handlers/base'; -import { AMSTAR2Handler } from './handlers/amstar2'; import { countProbe } from '../sync-perf'; -import { ROBINSIHandler } from './handlers/robins-i'; -import { ROB2Handler } from './handlers/rob2'; - -const handlers: Record = { - [CHECKLIST_TYPES.AMSTAR2]: new AMSTAR2Handler(), - [CHECKLIST_TYPES.ROBINS_I]: new ROBINSIHandler(), - [CHECKLIST_TYPES.ROB2]: new ROB2Handler(), -}; +import { getHandler } from './handlers/registry'; interface ResolvedAnswers { answersYMap: Y.Map; @@ -127,7 +117,7 @@ export function useChecklistAnswers( return null; } - const handler = handlers[resolved.checklistType]; + const handler = getHandler(resolved.checklistType); const fresh = handler ? handler.serializeAnswers(resolved.answersYMap) : fallbackSerialize(resolved.answersYMap); diff --git a/packages/web/src/primitives/useProject/sync-perf.ts b/packages/web/src/primitives/useProject/sync-perf.ts index dd2a694d..82c5bd9f 100644 --- a/packages/web/src/primitives/useProject/sync-perf.ts +++ b/packages/web/src/primitives/useProject/sync-perf.ts @@ -4,6 +4,7 @@ let cycleStart = 0; const counts: Record = { handleReviewsEvents: 0, buildStudy: 0, + buildStudySkipped: 0, serialize: 0, serializeCacheHit: 0, doSync: 0, @@ -18,7 +19,7 @@ function flush(): void { const elapsed = performance.now() - cycleStart; const parts = [ `handleReviewsEvents=${counts.handleReviewsEvents}`, - `buildStudy=${counts.buildStudy}(${buildStudyMs.toFixed(1)}ms)`, + `buildStudy=${counts.buildStudy}(${buildStudyMs.toFixed(1)}ms) skipped=${counts.buildStudySkipped}`, `serialize=${counts.serialize}(hit=${counts.serializeCacheHit})`, `doSync=${counts.doSync}`, `atomFired=${counts.atomFired}`, diff --git a/packages/web/src/primitives/useProject/sync.ts b/packages/web/src/primitives/useProject/sync.ts index e73b2306..7c9d8a8d 100644 --- a/packages/web/src/primitives/useProject/sync.ts +++ b/packages/web/src/primitives/useProject/sync.ts @@ -18,6 +18,7 @@ import { amstar2 } from '@corates/shared'; import { CHECKLIST_STATUS } from '@corates/shared/checklists'; import type { AMSTAR2Checklist } from '@corates/shared/checklists'; import { markCycleStart, countProbe, addBuildStudyTime } from './sync-perf'; +import { getHandler } from './checklists/handlers/registry'; const getAMSTAR2Answers = amstar2.getAnswers; @@ -51,36 +52,42 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null const reviewsMap = getYDoc()?.getMap('reviews'); if (!reviewsMap) return; - const dirtyStudyIds = new Set(); + const studyNeedsRebuild = new Map(); let structuralChange = false; for (const event of events) { if (event.path.length === 0 && 'keys' in event) { - // Top-level: studies added or removed from the reviews map structuralChange = true; for (const [key] of (event as Y.YMapEvent).keys) { - dirtyStudyIds.add(key); + studyNeedsRebuild.set(key, true); } } else if (event.path.length > 0) { - // Nested: something inside a specific study changed - dirtyStudyIds.add(String(event.path[0])); + const studyId = String(event.path[0]); + if (!studyNeedsRebuild.get(studyId)) { + studyNeedsRebuild.set(studyId, needsStudyRebuild(event)); + } } } - // Rebuild only dirty studies - for (const studyId of dirtyStudyIds) { + let anyRebuilt = false; + for (const [studyId, rebuild] of studyNeedsRebuild) { + if (!rebuild) { + countProbe('buildStudySkipped'); + continue; + } const studyYMap = reviewsMap.get(studyId) as Y.Map | undefined; if (studyYMap) { countProbe('buildStudy'); const t0 = performance.now(); studyCache.set(studyId, buildStudyFromYMap(studyId, studyYMap)); addBuildStudyTime(performance.now() - t0); + anyRebuilt = true; } else { studyCache.delete(studyId); + anyRebuilt = true; } } - // Clean up entries for studies that no longer exist in the Y.Map if (structuralChange) { for (const cachedId of studyCache.keys()) { if (!reviewsMap.has(cachedId)) { @@ -89,7 +96,7 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null } } - if (dirtyStudyIds.size > 0 || structuralChange) { + if (anyRebuilt || structuralChange) { sortedStudies = [...studyCache.values()].sort( (a, b) => (a.createdAt || 0) - (b.createdAt || 0), ); @@ -263,6 +270,24 @@ export function createSyncManager(projectId: string, getYDoc: () => Y.Doc | null return { syncFromYDocImmediate, attach, detach, pause, resume }; } +const CHECKLIST_REBUILD_KEYS = new Set(['status', 'type', 'assignedTo', 'outcomeId', 'title']); + +function needsStudyRebuild(event: Y.YEvent): boolean { + const path = event.path; + // path[0] is studyId (already extracted by caller) + // path[1] is the sub-map key (checklists, pdfs, reconciliation) + if (path.length <= 2) return true; + + if (path.length === 3 && path[1] === 'checklists' && 'keys' in event) { + for (const [key] of (event as Y.YMapEvent).keys) { + if (CHECKLIST_REBUILD_KEYS.has(key)) return true; + } + return false; + } + + return false; +} + function getStr(m: Y.Map, key: string): string | null { return (m.get(key) as string) || null; } @@ -402,138 +427,17 @@ function buildMembersList(membersMap: Y.Map): MemberEntry[] { return membersList; } -function isYText(value: unknown): value is Y.Text { - return ( - value !== null && - value !== undefined && - typeof (value as Y.Text).toString === 'function' && - typeof (value as Y.Text).insert === 'function' - ); -} - -function yTextToStr(value: unknown): string { - return isYText(value) ? value.toString() : ((value as string) ?? ''); -} - export function extractAnswersFromYMap( answersMap: Y.Map, checklistType: string, ): Record { - const answers: Record = {}; + const handler = getHandler(checklistType); + if (handler) return handler.serializeAnswers(answersMap); + const answers: Record = {}; for (const [key, sectionYMap] of answersMap.entries()) { - const section = sectionYMap as Y.Map; - - if (checklistType === 'ROBINS_I' && section && typeof section.get === 'function') { - if (key.startsWith('domain')) { - const sectionData: Record = { - judgement: section.get('judgement') ?? null, - answers: {} as Record, - }; - const direction = section.get('direction'); - if (direction !== undefined) sectionData.direction = direction; - - const answersNestedYMap = section.get('answers') as Y.Map | undefined; - if (answersNestedYMap && typeof answersNestedYMap.entries === 'function') { - const answersObj = sectionData.answers as Record< - string, - { answer: string | null; comment: string } - >; - for (const [qKey, questionYMap] of answersNestedYMap.entries()) { - const q = questionYMap as Y.Map; - if (q && typeof q.get === 'function') { - answersObj[qKey] = { - answer: (q.get('answer') as string) ?? null, - comment: (q.get('comment') as string) ?? '', - }; - } else { - answersObj[qKey] = questionYMap as { answer: string | null; comment: string }; - } - } - } - answers[key] = sectionData; - } else if (key === 'overall') { - const sectionData: Record = { - judgement: section.get('judgement') ?? null, - }; - const direction = section.get('direction'); - if (direction !== undefined) sectionData.direction = direction; - answers[key] = sectionData; - } else if (key === 'sectionB') { - const sectionData: Record = {}; - for (const [subKey, subValue] of section.entries()) { - const sv = subValue as Y.Map; - if (sv && typeof sv.get === 'function') { - sectionData[subKey] = { - answer: (sv.get('answer') as string) ?? null, - comment: (sv.get('comment') as string) ?? '', - }; - } else { - sectionData[subKey] = subValue; - } - } - answers[key] = sectionData; - } else { - const s = sectionYMap as { toJSON?: () => unknown }; - answers[key] = s.toJSON ? s.toJSON() : sectionYMap; - } - } else if (checklistType === 'ROB2' && section && typeof section.get === 'function') { - if (key.startsWith('domain')) { - const sectionData: Record = { - judgement: section.get('judgement') ?? null, - answers: {} as Record, - }; - const direction = section.get('direction'); - if (direction !== undefined) sectionData.direction = direction; - - const answersNestedYMap = section.get('answers') as Y.Map | undefined; - if (answersNestedYMap && typeof answersNestedYMap.entries === 'function') { - const answersObj = sectionData.answers as Record< - string, - { answer: string | null; comment: string } - >; - for (const [qKey, questionYMap] of answersNestedYMap.entries()) { - const q = questionYMap as Y.Map; - if (q && typeof q.get === 'function') { - answersObj[qKey] = { - answer: (q.get('answer') as string) ?? null, - comment: yTextToStr(q.get('comment')), - }; - } else { - answersObj[qKey] = questionYMap as { answer: string | null; comment: string }; - } - } - } - answers[key] = sectionData; - } else if (key === 'overall') { - const sectionData: Record = { - judgement: section.get('judgement') ?? null, - }; - const direction = section.get('direction'); - if (direction !== undefined) sectionData.direction = direction; - answers[key] = sectionData; - } else if (key === 'preliminary') { - answers[key] = { - studyDesign: section.get('studyDesign') ?? null, - experimental: yTextToStr(section.get('experimental')), - comparator: yTextToStr(section.get('comparator')), - numericalResult: yTextToStr(section.get('numericalResult')), - aim: section.get('aim') ?? null, - deviationsToAddress: section.get('deviationsToAddress') ?? [], - sources: section.get('sources') ?? {}, - }; - } else { - const sectionData: Record = {}; - for (const [fieldKey, fieldValue] of section.entries()) { - sectionData[fieldKey] = isYText(fieldValue) ? fieldValue.toString() : fieldValue; - } - answers[key] = sectionData; - } - } else { - const s = sectionYMap as { toJSON?: () => unknown }; - answers[key] = s.toJSON ? s.toJSON() : sectionYMap; - } + const s = sectionYMap as { toJSON?: () => unknown }; + answers[key] = s.toJSON ? s.toJSON() : sectionYMap; } - return answers; } From 4d81894823b7d761e24a0bf85a42c0b4378366fc Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 2 May 2026 07:43:56 -0500 Subject: [PATCH 04/28] fix initial loading not working from dexie --- .../reconcile-tab/ReconciliationWrapper.tsx | 18 ++++++++++++------ .../checklists/useChecklistAnswers.ts | 6 +++++- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx index b5a06d8d..96a3fda6 100644 --- a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx +++ b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.tsx @@ -8,6 +8,7 @@ import { useNavigate } from '@tanstack/react-router'; import { useProjectContext } from '@/components/project/ProjectContext'; import { connectionPool } from '@/project/ConnectionPool'; import { buildChecklistAnswerInput, type TextRef } from '@/primitives/useProject/checklists'; +import { useChecklistAnswers } from '@/primitives/useProject/checklists/useChecklistAnswers'; import { useProjectStore, selectConnectionPhase } from '@/stores/projectStore'; import { useStudy, useProjectMembers } from '@/stores/projectAtoms'; import { useAuthStore, selectUser } from '@/stores/authStore'; @@ -374,19 +375,24 @@ export function ReconciliationWrapper({ return currentStudy.checklists?.find(c => c.id === reconciledChecklistId); }, [currentStudy, reconciledChecklistId]); - // Get reconciled checklist data + // Reactive answers for the reconciled checklist so "Use This" updates + // propagate without relying on study-level atom rebuilds. + const reconciledAnswers = useChecklistAnswers( + projectId, + studyId, + reconciledChecklistId ?? '', + ); + const reconciledChecklistData = useMemo(() => { - if (!reconciledChecklistId || !getChecklistData) return null; - const data = getChecklistData(studyId, reconciledChecklistId); - if (!data) return null; + if (!reconciledChecklistId || !reconciledAnswers) return null; return { id: reconciledChecklistId, name: 'Reconciled Checklist', reviewerName: 'Consensus', createdAt: reconciledChecklistMeta?.createdAt || 0, - ...(data.answers ?? {}), + ...reconciledAnswers, }; - }, [reconciledChecklistId, getChecklistData, studyId, reconciledChecklistMeta]); + }, [reconciledChecklistId, reconciledAnswers, reconciledChecklistMeta?.createdAt]); // Build project path const getProjectPath = useCallback(() => `/projects/${projectId}`, [projectId]); diff --git a/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts b/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts index 4e2c56aa..4e8b17d3 100644 --- a/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts +++ b/packages/web/src/primitives/useProject/checklists/useChecklistAnswers.ts @@ -94,7 +94,11 @@ export function useChecklistAnswers( return () => checklistYMap.unobserve(observer); } - return () => {}; + // Data not available yet (e.g. DexieYProvider still loading). + // Watch the reviews map so we detect when the checklist arrives. + const reviewsMap = ydoc.getMap('reviews'); + reviewsMap.observeDeep(observer); + return () => reviewsMap.unobserveDeep(observer); }, [ydoc, studyId, checklistId], ); From 2a605fc9f60684c230466e249f9eb803240c10b0 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 2 May 2026 08:48:04 -0500 Subject: [PATCH 05/28] add a cool prototype --- .../audits/reactive-pipeline-audit-2026-05.md | 219 ++++++++++++++ packages/prototype/index.html | 12 + packages/prototype/package.json | 24 ++ packages/prototype/src/App.tsx | 80 +++++ .../src/components/ChecklistBadge.tsx | 37 +++ .../src/components/ChecklistEditor.tsx | 114 ++++++++ .../src/components/MutationConsole.tsx | 195 +++++++++++++ .../src/components/RenderTracker.tsx | 50 ++++ .../prototype/src/components/StatsPanel.tsx | 72 +++++ .../prototype/src/components/StudyCard.tsx | 72 +++++ .../prototype/src/components/StudyList.tsx | 33 +++ .../prototype/src/components/StudyName.tsx | 11 + .../src/components/StudyReviewer.tsx | 14 + packages/prototype/src/main.tsx | 9 + packages/prototype/src/reactor/context.tsx | 4 + packages/prototype/src/reactor/core.ts | 273 ++++++++++++++++++ packages/prototype/src/reactor/hooks.ts | 90 ++++++ packages/prototype/src/reactor/useYText.ts | 50 ++++ packages/prototype/src/seed.ts | 90 ++++++ packages/prototype/tsconfig.json | 14 + packages/prototype/vite.config.ts | 6 + pnpm-lock.yaml | 120 ++++++++ 22 files changed, 1589 insertions(+) create mode 100644 packages/docs/audits/reactive-pipeline-audit-2026-05.md create mode 100644 packages/prototype/index.html create mode 100644 packages/prototype/package.json create mode 100644 packages/prototype/src/App.tsx create mode 100644 packages/prototype/src/components/ChecklistBadge.tsx create mode 100644 packages/prototype/src/components/ChecklistEditor.tsx create mode 100644 packages/prototype/src/components/MutationConsole.tsx create mode 100644 packages/prototype/src/components/RenderTracker.tsx create mode 100644 packages/prototype/src/components/StatsPanel.tsx create mode 100644 packages/prototype/src/components/StudyCard.tsx create mode 100644 packages/prototype/src/components/StudyList.tsx create mode 100644 packages/prototype/src/components/StudyName.tsx create mode 100644 packages/prototype/src/components/StudyReviewer.tsx create mode 100644 packages/prototype/src/main.tsx create mode 100644 packages/prototype/src/reactor/context.tsx create mode 100644 packages/prototype/src/reactor/core.ts create mode 100644 packages/prototype/src/reactor/hooks.ts create mode 100644 packages/prototype/src/reactor/useYText.ts create mode 100644 packages/prototype/src/seed.ts create mode 100644 packages/prototype/tsconfig.json create mode 100644 packages/prototype/vite.config.ts diff --git a/packages/docs/audits/reactive-pipeline-audit-2026-05.md b/packages/docs/audits/reactive-pipeline-audit-2026-05.md new file mode 100644 index 00000000..b873aa8d --- /dev/null +++ b/packages/docs/audits/reactive-pipeline-audit-2026-05.md @@ -0,0 +1,219 @@ +# Reactive Pipeline Audit - May 2026 + +Audit of the Yjs -> React reactive pipeline. Focused on unnecessary work during checklist editing, but covers the full sync layer. + +## Architecture Summary + +``` +Y.Doc + | + |-- reviewsMap.observeDeep() -------> sync.ts (handleReviewsEvents) + | | + | +--> buildStudyFromYMap() per dirty study + | +--> RAF batch -> projectAtoms.setStudy() + | | + | +--> Consumers: + | useStudy() -> StudyCard, ChecklistYjsWrapper + | useAllStudies() -> OverviewTab, ToDoTab, CompletedTab + | useStudyIds() -> AllStudiesTab loop + | + |-- reviewsMap.observeDeep() -------> useChecklistAnswers (useSyncExternalStore) + | | + | +--> serialize() full answers Y.Map + | +--> Consumer: useChecklistViewModel -> checklist editor + | + |-- annotationsMap.observeDeep() ---> useStudyAnnotations (useSyncExternalStore) + | + +--> Consumer: AnnotationSyncManager in PDF viewer +``` + +Two parallel observer chains on the same Yjs data. The sync.ts path feeds list views via atoms. The direct hooks feed the checklist editor and annotation viewer. Both fire on broader changes than they need. + +## Problems + +### P1: `useChecklistAnswers` observes the entire reviews map + +`useChecklistAnswers.ts:81-86` subscribes to `reviewsMap.observeDeep()`. This means editing study A's name triggers a version bump and re-render in study B's checklist editor. Within the same study, any change (PDF upload, annotation add, reviewer assignment) triggers answer re-serialization even though answers didn't change. + +The `getSnapshot` cache prevents returning a new object when version hasn't changed, but it can't prevent the React render cycle that `useSyncExternalStore` initiates when `onStoreChange` fires. And when version does bump, `serialize()` walks the full answers tree and returns a new object every time, even if the underlying data is identical. + +**Impact**: Every Yjs mutation to any study triggers a render cycle in the checklist editor. For keystroke-heavy editing (notes fields), this compounds with the user's own edits. + +**Fix**: Observe the specific checklist's answers Y.Map instead of the whole reviews map. Falls back to a broader observer only when the answers map doesn't exist yet (first load / new checklist). + +### P2: `buildStudyFromYMap` does redundant work + +Two issues here: + +**a) `studyYMap.toJSON()` eagerly serializes everything.** +Line `sync.ts:72` calls `toJSON()` on the full study Y.Map, which recursively serializes all nested Y.Maps (checklists, annotations, PDFs, reconciliation). Then `buildStudyFromYMap` only uses the result for ~20 flat string fields (name, doi, authors, etc.) and re-walks the nested Y.Maps manually to build structured objects. The deep serialization of nested data in `toJSON()` is wasted work. + +**Fix**: Read flat fields directly from the Y.Map (`studyYMap.get('name')` etc.) instead of calling `toJSON()`. Drop the `studyData` parameter from `buildStudyFromYMap`. + +**b) Annotations are serialized but no longer consumed.** +`buildStudyFromYMap` calls `buildAnnotationsFromYMap()` (lines 396-399) which iterates all annotation Y.Maps and parses JSON `embedPdfData` strings. After the `useStudyAnnotations` hook was introduced, `study.annotations` has no remaining consumers -- no component reads it. This is dead work on every sync. + +**Fix**: Stop building annotations in `buildStudyFromYMap`. Remove the `annotations` field from `StudyInfo` if nothing reads it. If something needs it later, add a dedicated hook like `useStudyAnnotations`. + +### P3: Study atoms lack structural equality + +`projectAtoms.ts:29-31` sets the study atom with a new `StudyInfo` object on every sync. The atom has no custom `isEqual`, so `@tldraw/state` uses `Object.is` (reference equality). Since `buildStudyFromYMap` always creates a new object, the atom always notifies subscribers -- even when the user is editing checklist answers and nothing visible in the study card has changed. + +This means every checklist keystroke triggers re-renders in: `StudyCard`, `useAllStudies` consumers (OverviewTab, ToDoTab, CompletedTab, ReconcileTab), and anything else subscribed to that study's atom. + +**Fix**: Add `isEqual` to study atoms. The comparison doesn't need to be deep -- it should compare the fields that list views actually display: name, checklist statuses/scores, reviewer assignments, PDF count, updatedAt. Skip comparing `answers` and `annotations` since those are read through direct hooks. + +### P4: `handlePartialUpdate` doesn't batch across keys + +`ChecklistYjsWrapper.tsx:198-203` iterates `Object.entries(patch)` and calls `updateChecklistAnswer` per key. Each call is now wrapped in its own `ydoc.transact()` (from fix #1), but multiple keys still produce multiple transactions and multiple observer events. + +In practice most patches have a single key, so this is low impact. But when it does fire with multiple keys (e.g., clearing a domain resets judgement + answers), it produces unnecessary intermediate observer events. + +**Fix**: Wrap the loop itself in `ydoc.transact()` so multi-key patches produce a single Yjs event. + +### P5: `useAllStudies` is a computed that reads every study atom + +`projectAtoms.ts:107-119` implements `useAllStudies` as a `useValue` computed that reads `studyOrder` and then `.get()` on every study atom. In `@tldraw/state`, reading an atom inside a computed creates a dependency. So `useAllStudies` re-fires when ANY study atom changes. + +This is correct behavior (it needs the full list), but the consumers -- OverviewTab, ToDoTab, CompletedTab -- all derive filtered subsets from it. When study A's checklist answer changes, all three tabs re-render and re-filter even if the derived list hasn't changed (the study is still in the same tab, with the same status). + +This isn't a bug in `useAllStudies` itself -- it's a consequence of P3. If study atoms had structural equality and didn't fire on answer-only changes, `useAllStudies` would stay stable during checklist editing. + +**Impact**: Medium. The tab views are behind route-based lazy rendering, so only the active tab pays the render cost. But the active tab (usually "All Studies") does re-render on every edit. + +### P6: `updateProjectStats` runs on every sync + +`sync.ts:176` calls `useProjectStore.getState().updateProjectStats(projectId, studies)` on every `doSync()`. This iterates all studies, counts finalized checklists, and writes to localStorage. During active checklist editing this fires on every RAF-batched sync (every ~16ms while typing). + +**Fix**: Only call `updateProjectStats` when checklist status actually changes (FINALIZED count differs), or debounce it separately from the main sync. + +### P7: Score computation on every finalized checklist rebuild + +`buildStudyFromYMap` lines 326-336 call `scoreChecklistOfType()` for every FINALIZED checklist on every study rebuild. Scores for finalized checklists are immutable -- once finalized, answers can't change. + +**Fix**: Cache scores. Either store the computed score in the Y.Map when finalizing (alongside the status change), or cache in the study cache and skip recomputation when the checklist's `updatedAt` hasn't changed. + +## Measurement + +All fixes must be validated with before/after numbers. Add a dev-only performance monitor (gated behind `import.meta.env.DEV`) that logs per-edit-cycle stats to the console. Instrument these hot paths: + +| Probe | Location | What it measures | +|-------|----------|------------------| +| `handleReviewsEvents` | sync.ts | Fires per Yjs observer event. Count + time. | +| `buildStudyFromYMap` | sync.ts | Full study rebuilds. Count + time per call. | +| `serialize` | useChecklistAnswers.ts `getSnapshot` | Answer re-serializations. Count + cache hit/miss. | +| `doSync` | sync.ts | Store pushes. Count + time. | +| `studyAtom.set` | projectAtoms.ts `setStudy` | Atom notifications. Count + suppressed-by-isEqual count (after P3). | + +Use `performance.mark()` / `performance.measure()` for timing. Aggregate per edit cycle (reset on each `handleReviewsEvents` entry) and log a single summary line: + +``` +[perf] edit cycle: handleReviewsEvents=1 buildStudy=1 serialize=1 doSync=1 atomFired=1 (4.2ms) +``` + +### Measurement protocol + +1. Add instrumentation (P0). +2. Open a checklist with a PDF and annotations loaded. Project should have 5+ studies. +3. Record baseline: click 10 radio buttons, type 20 characters in a notes field. Capture console output. +4. Implement fixes one at a time. After each fix, repeat step 3 and record. +5. Confirm: observer counts drop, serialization counts drop, atom fire counts drop. Wall time should decrease. +6. Remove instrumentation before merging. + +Expected baseline (pre-fix, per radio button click): +- `handleReviewsEvents`: 2-3 calls (no transaction batching) +- `buildStudyFromYMap`: 2-3 calls +- `serialize`: 2-3 calls +- `doSync`: 1 call (RAF batched) +- `atomFired`: 1+ (always, no isEqual) + +Expected post-fix (per radio button click): +- `handleReviewsEvents`: 1 call (transaction batching already done) +- `buildStudyFromYMap`: 1 call +- `serialize`: 1 call, or 0 cache hits if observer is narrowed (P1) +- `doSync`: 1 call +- `atomFired`: 0 for answer-only edits (P3 isEqual suppresses) + +## Recommended Execution Order + +The fixes are listed below in order of impact-to-effort ratio. P3 and P1 together eliminate most of the unnecessary work. P2a and P2b are low-effort cleanup. P4-P7 are refinements. + +| Fix | Impact | Effort | Notes | +|-----|--------|--------|-------| +| P1: Narrow `useChecklistAnswers` observer | High | Small | Biggest single win for editor performance | +| P3: Add `isEqual` to study atoms | High | Small | Stops cascading re-renders to list views | +| P2b: Stop building annotations in sync | Medium | Small | Dead code removal | +| P2a: Drop `toJSON()` in handleReviewsEvents | Medium | Small | Avoids redundant deep serialization | +| P4: Batch `handlePartialUpdate` | Low | Tiny | Single `transact()` wrapper | +| P6: Gate `updateProjectStats` | Low | Small | Avoids localStorage writes during editing | +| P7: Cache finalized scores | Low | Small | Minor optimization, mostly for studies with many finalized checklists | +| P5: N/A (solved by P3) | - | - | Not a separate fix, just context | + +## Implementation Notes + +### P1 implementation sketch + +```typescript +// useChecklistAnswers.ts subscribe() +const subscribe = useCallback( + (onStoreChange: () => void) => { + if (!ydoc) return () => {}; + const resolved = resolveAnswers(ydoc, studyId, checklistId); + if (!resolved) { + // Answers map doesn't exist yet -- observe the checklist Y.Map + // to catch when answers are first created + const checklistYMap = resolveChecklistYMap(ydoc, studyId, checklistId); + if (!checklistYMap) return () => {}; + const observer = () => { versionRef.current += 1; onStoreChange(); }; + checklistYMap.observe(observer); + return () => checklistYMap.unobserve(observer); + } + const observer = () => { versionRef.current += 1; onStoreChange(); }; + resolved.answersYMap.observeDeep(observer); + return () => resolved.answersYMap.unobserveDeep(observer); + }, + [ydoc, studyId, checklistId], +); +``` + +The subtlety: the observer target can change during the component's lifetime (answers map gets created). `useSyncExternalStore` handles this via the `subscribe` dep array -- when `studyId` or `checklistId` changes, it re-subscribes. + +But there's a lifecycle gap: if the answers Y.Map is created AFTER the initial subscribe (e.g., first edit on a new checklist), the shallow `checklistYMap.observe` catches the creation event, bumps the version, and React calls `getSnapshot` which now resolves the answers. On the next subscribe call (triggered by deps or re-mount), it attaches the deep observer to the now-existing answers map. + +### P3 implementation sketch + +```typescript +// projectAtoms.ts +function studyEquals(a: StudyInfo | undefined, b: StudyInfo | undefined): boolean { + if (a === b) return true; + if (!a || !b) return false; + if (a.name !== b.name) return false; + if (a.updatedAt !== b.updatedAt) return false; + if (a.reviewer1 !== b.reviewer1 || a.reviewer2 !== b.reviewer2) return false; + if (a.pdfs.length !== b.pdfs.length) return false; + if (a.checklists.length !== b.checklists.length) return false; + for (let i = 0; i < a.checklists.length; i++) { + const ca = a.checklists[i], cb = b.checklists[i]; + if (ca.id !== cb.id || ca.status !== cb.status || ca.score !== cb.score + || ca.assignedTo !== cb.assignedTo || ca.updatedAt !== cb.updatedAt) return false; + } + return true; +} + +getOrCreateStudyAtom(studyId: string): Atom { + let a = this.studyAtoms.get(studyId); + if (!a) { + a = atom(`study:${studyId}`, undefined, { + isEqual: studyEquals, + }); + this.studyAtoms.set(studyId, a); + } + return a; +} +``` + +Key decision: include `updatedAt` in the comparison or not. Including it means the atom fires on every edit (since `updateChecklistAnswer` bumps it). Excluding it means list views won't show "last edited" updates in real-time, but the checklist editor doesn't cause cascading re-renders. Recommend excluding it from the equality check and instead using a dedicated "last activity" indicator that reads from Yjs awareness or a separate atom if needed. + +### P2b: removing annotations from StudyInfo + +After removing annotation building from `buildStudyFromYMap`, the `annotations` field on `StudyInfo` becomes dead. Removing it is a cascading change -- the type needs updating and any reference to `study.annotations` needs cleanup. From the search results, no component currently reads it (the only consumer was the useMemo we replaced with `useStudyAnnotations`). The `buildAnnotationsFromYMap` function and the `AnnotationEntry` type may still be needed by `useStudyAnnotations` -- check before removing. diff --git a/packages/prototype/index.html b/packages/prototype/index.html new file mode 100644 index 00000000..2687ffcb --- /dev/null +++ b/packages/prototype/index.html @@ -0,0 +1,12 @@ + + + + + + Reactor Prototype + + +
+ + + diff --git a/packages/prototype/package.json b/packages/prototype/package.json new file mode 100644 index 00000000..c1bb15aa --- /dev/null +++ b/packages/prototype/package.json @@ -0,0 +1,24 @@ +{ + "name": "@corates/prototype", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build" + }, + "dependencies": { + "@tldraw/state": "4.5.10", + "@tldraw/state-react": "4.5.10", + "react": "19.2.4", + "react-dom": "19.2.4", + "yjs": "^13.6.30" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "typescript": "^5.7.0", + "vite": "^6.0.0" + } +} diff --git a/packages/prototype/src/App.tsx b/packages/prototype/src/App.tsx new file mode 100644 index 00000000..72e18044 --- /dev/null +++ b/packages/prototype/src/App.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import * as Y from 'yjs'; +import { ProjectReactor } from './reactor/core'; +import { ProjectReactorContext } from './reactor/context'; +import { useChecklistIds } from './reactor/hooks'; +import { StudyList } from './components/StudyList'; +import { ChecklistEditor } from './components/ChecklistEditor'; +import { MutationConsole } from './components/MutationConsole'; +import { StatsPanel } from './components/StatsPanel'; +import { seedYDoc } from './seed'; + +function EditorPanel({ + studyId, +}: { + studyId: string; +}) { + const checklistIds = useChecklistIds(studyId); + + return ( +
+
+ Editor: {studyId} +
+ {checklistIds.length === 0 && ( +
No checklists on this study.
+ )} + {checklistIds.map((clId) => ( + + ))} +
+ ); +} + +export default function App() { + const [reactor] = useState(() => { + const ydoc = new Y.Doc(); + seedYDoc(ydoc); + return new ProjectReactor(ydoc); + }); + + const [selectedStudyId, setSelectedStudyId] = useState('study-1'); + + return ( + +
+

Reactor Prototype

+

+ Three data paths demonstrated: (1) Reactor for rendering -- per-field atoms via Y.Map.observe. + (2) Y.Text for collaborative editing -- direct subscription, invisible to reactor. + (3) Snapshot for export -- on-demand POJO read from Y.Map. Green flash = re-render. +

+ +
+
+
Studies
+ +
+ +
+ {selectedStudyId ? ( + + ) : ( +
+ Click a study to edit its checklists. +
+ )} +
+ +
+ + +
+
+
+
+ ); +} diff --git a/packages/prototype/src/components/ChecklistBadge.tsx b/packages/prototype/src/components/ChecklistBadge.tsx new file mode 100644 index 00000000..55ced0d5 --- /dev/null +++ b/packages/prototype/src/components/ChecklistBadge.tsx @@ -0,0 +1,37 @@ +import { useChecklistField } from '../reactor/hooks'; +import { RenderTracker } from './RenderTracker'; + +const STATUS_COLORS: Record = { + pending: '#94a3b8', + in_progress: '#facc15', + finalized: '#4ade80', +}; + +export function ChecklistBadge({ + studyId, + checklistId, +}: { + studyId: string; + checklistId: string; +}) { + const status = useChecklistField(studyId, checklistId, 'status'); + const type = useChecklistField(studyId, checklistId, 'type'); + + return ( + +
+ + {type ?? '?'} + {status} +
+
+ ); +} diff --git a/packages/prototype/src/components/ChecklistEditor.tsx b/packages/prototype/src/components/ChecklistEditor.tsx new file mode 100644 index 00000000..9bd5134b --- /dev/null +++ b/packages/prototype/src/components/ChecklistEditor.tsx @@ -0,0 +1,114 @@ +import { useMemo } from 'react'; +import { useChecklistField, useProjectReactor } from '../reactor/hooks'; +import { useYText, resolveYText } from '../reactor/useYText'; +import { RenderTracker } from './RenderTracker'; + +function ChecklistMeta({ + studyId, + checklistId, +}: { + studyId: string; + checklistId: string; +}) { + const status = useChecklistField(studyId, checklistId, 'status'); + const type = useChecklistField(studyId, checklistId, 'type'); + const assignedTo = useChecklistField(studyId, checklistId, 'assignedTo'); + + return ( + +
+ Type: {type} + Status: {status} + Assigned: {assignedTo ?? '(none)'} +
+
+ ); +} + +function AnswerRow({ + studyId, + checklistId, + questionKey, +}: { + studyId: string; + checklistId: string; + questionKey: string; +}) { + const { ydoc } = useProjectReactor(); + + const yText = useMemo( + () => resolveYText(ydoc, studyId, checklistId, questionKey, 'note'), + [ydoc, studyId, checklistId, questionKey], + ); + const [note, setNote] = useYText(yText); + + return ( + +
+ + {questionKey}: + +