From 1be34078e084cb0149f725ad9a0aad40e433eaf5 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 29 Jan 2026 15:35:18 +0000 Subject: [PATCH 1/7] Apply Prettier formatting --- packages/workers/src/auth/config.ts | 8 +-- packages/workers/src/auth/oauth-relay.ts | 62 ++++++++---------------- 2 files changed, 22 insertions(+), 48 deletions(-) diff --git a/packages/workers/src/auth/config.ts b/packages/workers/src/auth/config.ts index c18aa3f99..90a8fa7f8 100644 --- a/packages/workers/src/auth/config.ts +++ b/packages/workers/src/auth/config.ts @@ -1,13 +1,7 @@ import { betterAuth } from 'better-auth'; import { createAuthMiddleware } from 'better-auth/api'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; -import { - genericOAuth, - magicLink, - twoFactor, - admin, - organization, -} from 'better-auth/plugins'; +import { genericOAuth, magicLink, twoFactor, admin, organization } from 'better-auth/plugins'; import { oAuthRelay } from './oauth-relay'; import { stripe } from '@better-auth/stripe'; import Stripe from 'stripe'; diff --git a/packages/workers/src/auth/oauth-relay.ts b/packages/workers/src/auth/oauth-relay.ts index 52b5c2149..16af5d06e 100644 --- a/packages/workers/src/auth/oauth-relay.ts +++ b/packages/workers/src/auth/oauth-relay.ts @@ -101,7 +101,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { method: 'GET', query: relayQuerySchema, }, - async (ctx) => { + async ctx => { const { payload: encryptedPayload } = ctx.query; // Decrypt the payload @@ -113,9 +113,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { }); } catch (e) { ctx.context.logger.error('Failed to decrypt OAuth relay payload:', e); - throw ctx.redirect( - `${ctx.context.baseURL}/error?error=oauth_relay_decrypt_failed` - ); + throw ctx.redirect(`${ctx.context.baseURL}/error?error=oauth_relay_decrypt_failed`); } // Parse the payload @@ -124,20 +122,16 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { payload = JSON.parse(decrypted); } catch (e) { ctx.context.logger.error('Failed to parse OAuth relay payload:', e); - throw ctx.redirect( - `${ctx.context.baseURL}/error?error=oauth_relay_invalid_payload` - ); + throw ctx.redirect(`${ctx.context.baseURL}/error?error=oauth_relay_invalid_payload`); } // Check timestamp const age = (Date.now() - payload.timestamp) / 1000; if (age > maxAge || age < -10) { ctx.context.logger.error( - `OAuth relay payload expired (age: ${age}s, maxAge: ${maxAge}s)` - ); - throw ctx.redirect( - `${ctx.context.baseURL}/error?error=oauth_relay_expired` + `OAuth relay payload expired (age: ${age}s, maxAge: ${maxAge}s)`, ); + throw ctx.redirect(`${ctx.context.baseURL}/error?error=oauth_relay_expired`); } // Create user and session locally using Better Auth's internal handler @@ -156,7 +150,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { if (result.error) { ctx.context.logger.error('OAuth relay user creation failed:', result.error); throw ctx.redirect( - `${ctx.context.baseURL}/error?error=${encodeURIComponent(result.error)}` + `${ctx.context.baseURL}/error?error=${encodeURIComponent(result.error)}`, ); } @@ -167,7 +161,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { // Redirect to the original callback URL throw ctx.redirect(payload.callbackURL); - } + }, ), }, hooks: { @@ -182,7 +176,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { context.path?.startsWith('/sign-in/oauth2') ); }, - handler: createAuthMiddleware(async (ctx) => { + handler: createAuthMiddleware(async ctx => { const currentOrigin = getOrigin(ctx.context.baseURL); // Skip if we're on production (no relay needed) @@ -205,11 +199,10 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { */ matcher(context) { return !!( - context.path?.startsWith('/callback') || - context.path?.startsWith('/oauth2/callback') + context.path?.startsWith('/callback') || context.path?.startsWith('/oauth2/callback') ); }, - handler: createAuthMiddleware(async (ctx) => { + handler: createAuthMiddleware(async ctx => { const state = ctx.query?.state || ctx.body?.state; if (!state || typeof state !== 'string') return; @@ -234,9 +227,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { // This is a relay request - handle it ourselves const code = ctx.query?.code || ctx.body?.code; if (!code) { - throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=no_code` - ); + throw ctx.redirect(`${relayPackage.relayOrigin}/api/auth/error?error=no_code`); } // Get the provider ID from the request URL (not ctx.path which is the route pattern) @@ -255,20 +246,16 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { if (!providerId) { ctx.context.logger.error('Could not extract provider ID from URL:', requestUrl); - throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=no_provider` - ); + throw ctx.redirect(`${relayPackage.relayOrigin}/api/auth/error?error=no_provider`); } // Find the OAuth provider - const provider = ctx.context.socialProviders.find( - (p) => p.id === providerId - ); + const provider = ctx.context.socialProviders.find(p => p.id === providerId); if (!provider) { ctx.context.logger.error('Provider not found:', providerId); throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=provider_not_found` + `${relayPackage.relayOrigin}/api/auth/error?error=provider_not_found`, ); } @@ -283,7 +270,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { } catch (e) { ctx.context.logger.error('Failed to decrypt state cookie:', e); throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=invalid_state_cookie` + `${relayPackage.relayOrigin}/api/auth/error?error=invalid_state_cookie`, ); } @@ -298,14 +285,12 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { } catch (e) { ctx.context.logger.error('Token exchange failed:', e); throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=token_exchange_failed` + `${relayPackage.relayOrigin}/api/auth/error?error=token_exchange_failed`, ); } if (!tokens) { - throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=no_tokens` - ); + throw ctx.redirect(`${relayPackage.relayOrigin}/api/auth/error?error=no_tokens`); } // Get user info from provider @@ -314,9 +299,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { if (!userInfo || !userInfo.email) { ctx.context.logger.error('Failed to get user info'); - throw ctx.redirect( - `${relayPackage.relayOrigin}/api/auth/error?error=no_user_info` - ); + throw ctx.redirect(`${relayPackage.relayOrigin}/api/auth/error?error=no_user_info`); } // Build relay payload @@ -370,7 +353,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { context.path?.startsWith('/sign-in/oauth2') ); }, - handler: createAuthMiddleware(async (ctx) => { + handler: createAuthMiddleware(async ctx => { const relayOrigin = (ctx.context as any)._relayOrigin; if (!relayOrigin) return; @@ -378,7 +361,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { if (ctx.context.oauthConfig.storeStateStrategy !== 'cookie') { ctx.context.logger.warn( 'OAuth relay requires storeStateStrategy: "cookie". Current:', - ctx.context.oauthConfig.storeStateStrategy + ctx.context.oauthConfig.storeStateStrategy, ); return; } @@ -410,10 +393,7 @@ export const oAuthRelay = (opts: OAuthRelayOptions) => { const stateCookie = ctx.context.createAuthCookie('oauth_state'); // Cookie header can have multiple cookies, need to find the right one // Format: "name=value; attributes, name2=value2; attributes" - const cookieRegex = new RegExp( - `(?:^|,\\s*)${stateCookie.name}=([^;]+)`, - 'i' - ); + const cookieRegex = new RegExp(`(?:^|,\\s*)${stateCookie.name}=([^;]+)`, 'i'); const match = setCookieHeader.match(cookieRegex); const stateCookieValue = match?.[1]; From cfd7be48781868dfac132309a0be4d5dcf93bb2f Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 29 Jan 2026 10:54:25 -0600 Subject: [PATCH 2/7] feat: amstar, rob, robins text fields now work on local checklists --- .../checklist/LocalChecklistView.jsx | 41 ++-- .../checklist/common/LocalTextAdapter.js | 212 ++++++++++++++++++ 2 files changed, 240 insertions(+), 13 deletions(-) create mode 100644 packages/web/src/components/checklist/common/LocalTextAdapter.js diff --git a/packages/web/src/components/checklist/LocalChecklistView.jsx b/packages/web/src/components/checklist/LocalChecklistView.jsx index 78720cbd1..b191cb2bc 100644 --- a/packages/web/src/components/checklist/LocalChecklistView.jsx +++ b/packages/web/src/components/checklist/LocalChecklistView.jsx @@ -15,6 +15,7 @@ import localChecklistsStore from '@/stores/localChecklistsStore'; import { getChecklistTypeFromState, scoreChecklistOfType } from '@/checklist-registry'; import { IoChevronBack } from 'solid-icons/io'; import ScoreTag from '@/components/checklist/ScoreTag.jsx'; +import { createLocalAdapterFactories } from '@/components/checklist/common/LocalTextAdapter.js'; export default function LocalChecklistView() { const params = useParams(); @@ -28,6 +29,22 @@ export default function LocalChecklistView() { const [loading, setLoading] = createSignal(true); const [error, setError] = createSignal(null); + // Debounced save function (defined before adapters since they use it) + const debouncedSave = debounce(async updates => { + try { + await updateChecklist(params.checklistId, updates); + } catch (err) { + console.error('Error saving checklist:', err); + } + }, 500); + + // Create adapter factories for text fields + const { getRob2Text, getQuestionNote, getRobinsText, clearCache } = createLocalAdapterFactories( + () => checklist(), + setChecklist, + debouncedSave + ); + // Load the checklist and PDF on mount createEffect(() => { const checklistId = params.checklistId; @@ -55,6 +72,8 @@ export default function LocalChecklistView() { return; } + // Clear adapter cache when loading new data to prevent stale values + clearCache(); setChecklist(loaded); // Load saved PDF if exists @@ -75,30 +94,23 @@ export default function LocalChecklistView() { })(); }); - // Debounced save function - const debouncedSave = debounce(async (checklistId, updates) => { - try { - await updateChecklist(checklistId, updates); - } catch (err) { - console.error('Error saving checklist:', err); - } - }, 500); - // Cleanup on unmount onCleanup(() => { debouncedSave.clear(); + clearCache(); }); - // Handle updates from the AMSTAR2Checklist component + // Handle updates from checklist components (answers, judgements, etc.) const handleUpdate = updates => { - // Optimistically update local state setChecklist(prev => { if (!prev) return prev; return { ...prev, ...updates }; }); - // Debounce the save to IndexedDB - debouncedSave(params.checklistId, updates); + // Clear adapter cache to ensure text fields sync with new state + clearCache(); + + debouncedSave(updates); }; // Handle PDF change @@ -209,6 +221,9 @@ export default function LocalChecklistView() { onPdfChange={handlePdfChange} onPdfClear={handlePdfClear} allowDelete={true} + getQuestionNote={getQuestionNote} + getRob2Text={getRob2Text} + getRobinsText={getRobinsText} /> diff --git a/packages/web/src/components/checklist/common/LocalTextAdapter.js b/packages/web/src/components/checklist/common/LocalTextAdapter.js new file mode 100644 index 000000000..7ca60ec95 --- /dev/null +++ b/packages/web/src/components/checklist/common/LocalTextAdapter.js @@ -0,0 +1,212 @@ +/** + * LocalTextAdapter - Y.Text-compatible adapter for local checklist comments + * + * Mimics Y.Text interface to enable NoteEditor to work with plain strings + * in local checklists without requiring full Yjs infrastructure. + */ + +/** + * Creates adapter factory functions for local checklist text fields. + * Encapsulates all the path resolution and caching logic. + * + * @param {Function} getChecklist - Getter for current checklist state + * @param {Function} updateState - Function to update checklist state: (updater: (prev) => next) => void + * @param {Function} save - Function to persist changes: (updates) => void + * @returns {Object} Factory functions and cache control + */ +export function createLocalAdapterFactories(getChecklist, updateState, save) { + const cache = new Map(); + + function getOrCreateAdapter(path, getValue, getUpdatedState) { + if (cache.has(path)) { + return cache.get(path); + } + + const currentValue = getValue(getChecklist()); + + const adapter = new LocalTextAdapter(currentValue, newValue => { + updateState(prev => { + if (!prev) return prev; + const updated = getUpdatedState(prev, newValue); + save(updated); + return updated; + }); + }); + + cache.set(path, adapter); + return adapter; + } + + // ROB2: domain comments and preliminary text fields + function getRob2Text(sectionKey, fieldKey, questionKey) { + let path, getValue, getUpdatedState; + + if (sectionKey.startsWith('domain') && questionKey) { + path = `${sectionKey}.${questionKey}.comment`; + getValue = cl => cl?.[sectionKey]?.answers?.[questionKey]?.comment || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + answers: { + ...prev[sectionKey]?.answers, + [questionKey]: { + ...prev[sectionKey]?.answers?.[questionKey], + comment: newValue, + }, + }, + }, + }); + } else if (sectionKey === 'preliminary') { + path = `preliminary.${fieldKey}`; + getValue = cl => cl?.preliminary?.[fieldKey] || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + preliminary: { + ...prev.preliminary, + [fieldKey]: newValue, + }, + }); + } else { + return null; + } + + return getOrCreateAdapter(path, getValue, getUpdatedState); + } + + // AMSTAR2: question notes + function getQuestionNote(questionKey) { + const path = `notes.${questionKey}`; + const getValue = cl => cl?.notes?.[questionKey] || ''; + const getUpdatedState = (prev, newValue) => ({ + ...prev, + notes: { + ...prev.notes, + [questionKey]: newValue, + }, + }); + + return getOrCreateAdapter(path, getValue, getUpdatedState); + } + + // ROBINS-I: domain comments, sectionB comments, and section text fields + function getRobinsText(sectionKey, fieldKey, questionKey) { + let path, getValue, getUpdatedState; + + if (sectionKey.startsWith('domain') && questionKey) { + path = `${sectionKey}.${questionKey}.comment`; + getValue = cl => cl?.[sectionKey]?.answers?.[questionKey]?.comment || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + answers: { + ...prev[sectionKey]?.answers, + [questionKey]: { + ...prev[sectionKey]?.answers?.[questionKey], + comment: newValue, + }, + }, + }, + }); + } else if (sectionKey === 'sectionB' && questionKey) { + path = `sectionB.${questionKey}.comment`; + getValue = cl => cl?.sectionB?.[questionKey]?.comment || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + sectionB: { + ...prev.sectionB, + [questionKey]: { + ...prev.sectionB?.[questionKey], + comment: newValue, + }, + }, + }); + } else if (['sectionA', 'sectionC', 'sectionD', 'planning'].includes(sectionKey)) { + path = `${sectionKey}.${fieldKey}`; + getValue = cl => cl?.[sectionKey]?.[fieldKey] || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + [fieldKey]: newValue, + }, + }); + } else { + return null; + } + + return getOrCreateAdapter(path, getValue, getUpdatedState); + } + + function clearCache() { + cache.clear(); + } + + return { + getRob2Text, + getQuestionNote, + getRobinsText, + clearCache, + }; +} + +export class LocalTextAdapter { + constructor(initialValue = '', onUpdate) { + this._value = initialValue; + this._onUpdate = onUpdate; + this._observers = new Set(); + + // Mock doc object with transact method (executes immediately) + this.doc = { + transact: fn => { + fn(); + }, + }; + } + + toString() { + return this._value; + } + + get length() { + return this._value.length; + } + + observe(callback) { + this._observers.add(callback); + } + + unobserve(callback) { + this._observers.delete(callback); + } + + delete(index, length) { + const before = this._value.substring(0, index); + const after = this._value.substring(index + length); + this._value = before + after; + this._notifyUpdate(); + } + + insert(index, text) { + const before = this._value.substring(0, index); + const after = this._value.substring(index); + this._value = before + text + after; + this._notifyUpdate(); + } + + _notifyUpdate() { + if (this._onUpdate) { + this._onUpdate(this._value); + } + this._observers.forEach(callback => callback()); + } + + // Allow external sync of value (e.g., when checklist reloads) + _syncValue(newValue) { + if (this._value !== newValue) { + this._value = newValue; + this._observers.forEach(callback => callback()); + } + } +} From d75cef44618705d2c89d8e9ce719716549ae3f55 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 29 Jan 2026 16:55:16 +0000 Subject: [PATCH 3/7] Apply Prettier formatting --- packages/web/src/components/checklist/LocalChecklistView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/checklist/LocalChecklistView.jsx b/packages/web/src/components/checklist/LocalChecklistView.jsx index b191cb2bc..ee5b725c1 100644 --- a/packages/web/src/components/checklist/LocalChecklistView.jsx +++ b/packages/web/src/components/checklist/LocalChecklistView.jsx @@ -42,7 +42,7 @@ export default function LocalChecklistView() { const { getRob2Text, getQuestionNote, getRobinsText, clearCache } = createLocalAdapterFactories( () => checklist(), setChecklist, - debouncedSave + debouncedSave, ); // Load the checklist and PDF on mount From 1ab14ce466172c51c832517a7ca9e51a78b1649c Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Thu, 29 Jan 2026 10:54:25 -0600 Subject: [PATCH 4/7] disable reactivity lint --- .../checklist/LocalChecklistView.jsx | 42 ++-- .../checklist/common/LocalTextAdapter.js | 212 ++++++++++++++++++ 2 files changed, 241 insertions(+), 13 deletions(-) create mode 100644 packages/web/src/components/checklist/common/LocalTextAdapter.js diff --git a/packages/web/src/components/checklist/LocalChecklistView.jsx b/packages/web/src/components/checklist/LocalChecklistView.jsx index 78720cbd1..05af3b6e9 100644 --- a/packages/web/src/components/checklist/LocalChecklistView.jsx +++ b/packages/web/src/components/checklist/LocalChecklistView.jsx @@ -15,6 +15,7 @@ import localChecklistsStore from '@/stores/localChecklistsStore'; import { getChecklistTypeFromState, scoreChecklistOfType } from '@/checklist-registry'; import { IoChevronBack } from 'solid-icons/io'; import ScoreTag from '@/components/checklist/ScoreTag.jsx'; +import { createLocalAdapterFactories } from '@/components/checklist/common/LocalTextAdapter.js'; export default function LocalChecklistView() { const params = useParams(); @@ -28,6 +29,23 @@ export default function LocalChecklistView() { const [loading, setLoading] = createSignal(true); const [error, setError] = createSignal(null); + // Debounced save function (defined before adapters since they use it) + const debouncedSave = debounce(async updates => { + try { + await updateChecklist(params.checklistId, updates); + } catch (err) { + console.error('Error saving checklist:', err); + } + }, 500); + + // Create adapter factories for text fields + const { getRob2Text, getQuestionNote, getRobinsText, clearCache } = createLocalAdapterFactories( + // eslint-disable-next-line solid/reactivity + () => checklist(), + setChecklist, + debouncedSave + ); + // Load the checklist and PDF on mount createEffect(() => { const checklistId = params.checklistId; @@ -55,6 +73,8 @@ export default function LocalChecklistView() { return; } + // Clear adapter cache when loading new data to prevent stale values + clearCache(); setChecklist(loaded); // Load saved PDF if exists @@ -75,30 +95,23 @@ export default function LocalChecklistView() { })(); }); - // Debounced save function - const debouncedSave = debounce(async (checklistId, updates) => { - try { - await updateChecklist(checklistId, updates); - } catch (err) { - console.error('Error saving checklist:', err); - } - }, 500); - // Cleanup on unmount onCleanup(() => { debouncedSave.clear(); + clearCache(); }); - // Handle updates from the AMSTAR2Checklist component + // Handle updates from checklist components (answers, judgements, etc.) const handleUpdate = updates => { - // Optimistically update local state setChecklist(prev => { if (!prev) return prev; return { ...prev, ...updates }; }); - // Debounce the save to IndexedDB - debouncedSave(params.checklistId, updates); + // Clear adapter cache to ensure text fields sync with new state + clearCache(); + + debouncedSave(updates); }; // Handle PDF change @@ -209,6 +222,9 @@ export default function LocalChecklistView() { onPdfChange={handlePdfChange} onPdfClear={handlePdfClear} allowDelete={true} + getQuestionNote={getQuestionNote} + getRob2Text={getRob2Text} + getRobinsText={getRobinsText} /> diff --git a/packages/web/src/components/checklist/common/LocalTextAdapter.js b/packages/web/src/components/checklist/common/LocalTextAdapter.js new file mode 100644 index 000000000..7ca60ec95 --- /dev/null +++ b/packages/web/src/components/checklist/common/LocalTextAdapter.js @@ -0,0 +1,212 @@ +/** + * LocalTextAdapter - Y.Text-compatible adapter for local checklist comments + * + * Mimics Y.Text interface to enable NoteEditor to work with plain strings + * in local checklists without requiring full Yjs infrastructure. + */ + +/** + * Creates adapter factory functions for local checklist text fields. + * Encapsulates all the path resolution and caching logic. + * + * @param {Function} getChecklist - Getter for current checklist state + * @param {Function} updateState - Function to update checklist state: (updater: (prev) => next) => void + * @param {Function} save - Function to persist changes: (updates) => void + * @returns {Object} Factory functions and cache control + */ +export function createLocalAdapterFactories(getChecklist, updateState, save) { + const cache = new Map(); + + function getOrCreateAdapter(path, getValue, getUpdatedState) { + if (cache.has(path)) { + return cache.get(path); + } + + const currentValue = getValue(getChecklist()); + + const adapter = new LocalTextAdapter(currentValue, newValue => { + updateState(prev => { + if (!prev) return prev; + const updated = getUpdatedState(prev, newValue); + save(updated); + return updated; + }); + }); + + cache.set(path, adapter); + return adapter; + } + + // ROB2: domain comments and preliminary text fields + function getRob2Text(sectionKey, fieldKey, questionKey) { + let path, getValue, getUpdatedState; + + if (sectionKey.startsWith('domain') && questionKey) { + path = `${sectionKey}.${questionKey}.comment`; + getValue = cl => cl?.[sectionKey]?.answers?.[questionKey]?.comment || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + answers: { + ...prev[sectionKey]?.answers, + [questionKey]: { + ...prev[sectionKey]?.answers?.[questionKey], + comment: newValue, + }, + }, + }, + }); + } else if (sectionKey === 'preliminary') { + path = `preliminary.${fieldKey}`; + getValue = cl => cl?.preliminary?.[fieldKey] || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + preliminary: { + ...prev.preliminary, + [fieldKey]: newValue, + }, + }); + } else { + return null; + } + + return getOrCreateAdapter(path, getValue, getUpdatedState); + } + + // AMSTAR2: question notes + function getQuestionNote(questionKey) { + const path = `notes.${questionKey}`; + const getValue = cl => cl?.notes?.[questionKey] || ''; + const getUpdatedState = (prev, newValue) => ({ + ...prev, + notes: { + ...prev.notes, + [questionKey]: newValue, + }, + }); + + return getOrCreateAdapter(path, getValue, getUpdatedState); + } + + // ROBINS-I: domain comments, sectionB comments, and section text fields + function getRobinsText(sectionKey, fieldKey, questionKey) { + let path, getValue, getUpdatedState; + + if (sectionKey.startsWith('domain') && questionKey) { + path = `${sectionKey}.${questionKey}.comment`; + getValue = cl => cl?.[sectionKey]?.answers?.[questionKey]?.comment || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + answers: { + ...prev[sectionKey]?.answers, + [questionKey]: { + ...prev[sectionKey]?.answers?.[questionKey], + comment: newValue, + }, + }, + }, + }); + } else if (sectionKey === 'sectionB' && questionKey) { + path = `sectionB.${questionKey}.comment`; + getValue = cl => cl?.sectionB?.[questionKey]?.comment || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + sectionB: { + ...prev.sectionB, + [questionKey]: { + ...prev.sectionB?.[questionKey], + comment: newValue, + }, + }, + }); + } else if (['sectionA', 'sectionC', 'sectionD', 'planning'].includes(sectionKey)) { + path = `${sectionKey}.${fieldKey}`; + getValue = cl => cl?.[sectionKey]?.[fieldKey] || ''; + getUpdatedState = (prev, newValue) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + [fieldKey]: newValue, + }, + }); + } else { + return null; + } + + return getOrCreateAdapter(path, getValue, getUpdatedState); + } + + function clearCache() { + cache.clear(); + } + + return { + getRob2Text, + getQuestionNote, + getRobinsText, + clearCache, + }; +} + +export class LocalTextAdapter { + constructor(initialValue = '', onUpdate) { + this._value = initialValue; + this._onUpdate = onUpdate; + this._observers = new Set(); + + // Mock doc object with transact method (executes immediately) + this.doc = { + transact: fn => { + fn(); + }, + }; + } + + toString() { + return this._value; + } + + get length() { + return this._value.length; + } + + observe(callback) { + this._observers.add(callback); + } + + unobserve(callback) { + this._observers.delete(callback); + } + + delete(index, length) { + const before = this._value.substring(0, index); + const after = this._value.substring(index + length); + this._value = before + after; + this._notifyUpdate(); + } + + insert(index, text) { + const before = this._value.substring(0, index); + const after = this._value.substring(index); + this._value = before + text + after; + this._notifyUpdate(); + } + + _notifyUpdate() { + if (this._onUpdate) { + this._onUpdate(this._value); + } + this._observers.forEach(callback => callback()); + } + + // Allow external sync of value (e.g., when checklist reloads) + _syncValue(newValue) { + if (this._value !== newValue) { + this._value = newValue; + this._observers.forEach(callback => callback()); + } + } +} From 7eb44c58ba519cc09f0931a233049eabb5189308 Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Thu, 29 Jan 2026 16:56:40 +0000 Subject: [PATCH 5/7] Apply Prettier formatting --- packages/web/src/components/checklist/LocalChecklistView.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/components/checklist/LocalChecklistView.jsx b/packages/web/src/components/checklist/LocalChecklistView.jsx index 05af3b6e9..8c1f1aab8 100644 --- a/packages/web/src/components/checklist/LocalChecklistView.jsx +++ b/packages/web/src/components/checklist/LocalChecklistView.jsx @@ -43,7 +43,7 @@ export default function LocalChecklistView() { // eslint-disable-next-line solid/reactivity () => checklist(), setChecklist, - debouncedSave + debouncedSave, ); // Load the checklist and PDF on mount From c214af7fb308202dfd2d361312774a7eb77e3789 Mon Sep 17 00:00:00 2001 From: Jacob Maynard Date: Sat, 31 Jan 2026 17:59:45 -0600 Subject: [PATCH 6/7] remove db --- .mcp/memory.db-shm | Bin 32768 -> 0 bytes .mcp/memory.db-wal | 0 2 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .mcp/memory.db-shm delete mode 100644 .mcp/memory.db-wal diff --git a/.mcp/memory.db-shm b/.mcp/memory.db-shm deleted file mode 100644 index fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32768 zcmeIuAr62r3 Date: Sat, 31 Jan 2026 18:03:34 -0600 Subject: [PATCH 7/7] fix debounce update --- .mcp/memory.db-shm | Bin 0 -> 32768 bytes .mcp/memory.db-wal | 0 .../components/checklist/LocalChecklistView.jsx | 15 +++++++++++---- .../checklist/common/LocalTextAdapter.js | 5 +++-- 4 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 .mcp/memory.db-shm create mode 100644 .mcp/memory.db-wal diff --git a/.mcp/memory.db-shm b/.mcp/memory.db-shm new file mode 100644 index 0000000000000000000000000000000000000000..fe9ac2845eca6fe6da8a63cd096d9cf9e24ece10 GIT binary patch literal 32768 zcmeIuAr62r3 { + // Debounced save function always saves the full current checklist state + // to avoid race conditions where rapid partial updates could overwrite each other. + // Ignores arguments and reads checklist() when it fires to get the latest merged state. + // eslint-disable-next-line solid/reactivity + const debouncedSave = debounce(async () => { try { - await updateChecklist(params.checklistId, updates); + const current = checklist(); + if (current) { + await updateChecklist(params.checklistId, current); + } } catch (err) { console.error('Error saving checklist:', err); } @@ -111,7 +117,8 @@ export default function LocalChecklistView() { // Clear adapter cache to ensure text fields sync with new state clearCache(); - debouncedSave(updates); + // Trigger debounced save - it will read checklist() when it fires + debouncedSave(); }; // Handle PDF change diff --git a/packages/web/src/components/checklist/common/LocalTextAdapter.js b/packages/web/src/components/checklist/common/LocalTextAdapter.js index 7ca60ec95..ba341490d 100644 --- a/packages/web/src/components/checklist/common/LocalTextAdapter.js +++ b/packages/web/src/components/checklist/common/LocalTextAdapter.js @@ -11,7 +11,7 @@ * * @param {Function} getChecklist - Getter for current checklist state * @param {Function} updateState - Function to update checklist state: (updater: (prev) => next) => void - * @param {Function} save - Function to persist changes: (updates) => void + * @param {Function} save - Function to trigger debounced persistence (reads current state when it fires) * @returns {Object} Factory functions and cache control */ export function createLocalAdapterFactories(getChecklist, updateState, save) { @@ -28,7 +28,8 @@ export function createLocalAdapterFactories(getChecklist, updateState, save) { updateState(prev => { if (!prev) return prev; const updated = getUpdatedState(prev, newValue); - save(updated); + // Trigger save after state is updated - save() reads current state when it fires + save(); return updated; }); });