Skip to content

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) - #1790

Merged
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring
Apr 28, 2026
Merged

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780)#1790
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring

Conversation

@CraigBuckmaster

Copy link
Copy Markdown
Owner

Phase 1 of Epic #820 — wire panel_filter / panel_order in ChapterScreen

Closes#1780.

With Phase 0 (PR #1779) the schema, content directory, and loader for chapter_lens_content.panel_filter_json / panel_order_json shipped — but ChapterScreen.tsx consumed only lensContent.guidance, leaving the filter/order JSON columns dead. This PR makes them actually do something.

End user impact: when a chapter has lens content with a panel_filter or panel_order, activating that lens now hides non-listed panels and reorders the matching ones. The "Default" toggle restores the full view.

Utility (new)

app/src/utils/lensPanelFilter.ts — four pure functions:

FunctionBehaviour
parseLensJson(json)JSON.parse with logger.warn on failure; returns undefined for missing / malformed / non-array / non-string-array inputs
filterPanelKeys(keys, filter)Empty filter is treated as no filter (avoids rendering zero panels from an authoring bug). Original key order preserved.
orderPanelKeys(keys, order)Keys in order come first; keys not in order appended in original relative order; missing-from-actual-keys entries silently dropped
applyLensToKeys(keys, filter, order)Convenience: filter then order in one pass

Tests (new)

app/__tests__/unit/lensPanelFilter.test.ts28 cases covering:

  • parseLensJson: undefined/null/empty → undefined; valid array → strings; empty array; malformed JSON → undefined + warn; non-array JSON → undefined; non-string entries dropped + warn
  • filterPanelKeys: undefined filter; empty filter (deliberate no-op); kept-keys preserve original order; missing-from-keys filter entries dropped; no-overlap → []; input not mutated
  • orderPanelKeys: undefined/empty order; full reorder; partial order with appended unordered keys; missing-from-keys order entries dropped; duplicate order entries deduped; input not mutated
  • applyLensToKeys: filter+order combined; only filter; only order; order keys outside filter dropped
  • End-to-end: parsed JSON strings → applied; both malformed → keys unchanged

Wiring in ChapterScreen

The lens-data hooks (useAvailableLenses, useChapterLensContent) were previously fetched afteruseChapterPanels. Moved them earlier so derived filtered sections / chapterPanels can flow into the panel hook.

  • lensPanelFilter / lensPanelOrderuseMemos wrapping parseLensJson
  • lensIsActive — true when a lens is selected AND has filter or order data
  • lensFilteredSectionsuseMemo. When inactive, returns sections by reference (memoised consumers don't invalidate). When active, each section gets a copy with its panels[] filtered + reordered by panel_type.
  • lensFilteredChapterPanels — same treatment for the flat chapter-panels list

Downstream changes:

  • useChapterPanels({ sections: lensFilteredSections, ... }) — depth-tracking and panel-open handlers see the lens view
  • ChapterVerseList receives both filtered versions
  • studyEstimate uses the filtered panels so the chapter's depth estimate reflects the lens-trimmed view

What's NOT changed

  • useChapterTTS still receives the raw sections array — TTS reads verse text, not panels, so the lens filter has no relevance there.
  • The destructured sections and chapterPanels from useChapterData remain raw. They're the source of truth; lens filtering is a presentation transform only.

Behaviour decisions (pinned in tests)

  • Empty filter array → treat as "no filter". An empty array would render zero panels, which is almost certainly an authoring bug rather than intent. The schema validator already rejects empty panel_filter arrays at content time; this is the runtime safety net.
  • Order keys not present in panels → silently dropped, no crash
  • Panels not present in order → appended after ordered keys, preserving original relative order
  • Bad JSON in either column → logger.warn once, fallback to no transformation

Out of scope

Rollback plan

Pure additive refactor. The behaviour when no lens is active is byte-identical to master (verified by referential equality in the useMemo short-circuits). git revert is clean — nothing depends on these new helpers yet outside of the screen itself.

Note on bookkeeping

This issue was briefly closed on a false premise (I claimed PR #1779 contained this work; it didn't — see correction comment on #1780). Reopened, repeated, and shipping properly here.

Phase 1 of Epic #820. With Phase 0 the schema, content directory, and
loader for chapter_lens_content.panel_filter_json / panel_order_json
shipped, but ChapterScreen consumed only lensContent.guidance — the
filter/order JSON columns were dead. This PR makes them actually do
something.
Utility (new):
- app/src/utils/lensPanelFilter.ts — pure functions:
* parseLensJson(json) — JSON.parse with logger.warn on failure,
returns undefined for missing/malformed/non-array/non-string
* filterPanelKeys(keys, filter) — empty filter == no filter (avoids
rendering zero panels from an authoring bug)
* orderPanelKeys(keys, order) — keys in order first, then unordered
keys appended in original relative order; missing-from-order keys
silently dropped
* applyLensToKeys(keys, filter, order) — convenience that filters
then orders in one pass
Tests (new):
- app/__tests__/unit/lensPanelFilter.test.ts — 28 cases covering
empty filter (deliberate behaviour), missing keys in order, both
fields together, malformed JSON, non-string entries, mutation
safety, and the parseLensJson + applyLensToKeys end-to-end flow.
Wiring in ChapterScreen:
- Lens-data hooks moved earlier in the render flow so derived
filtered sections / chapterPanels can flow into useChapterPanels.
- New lensFilteredSections useMemo — when no lens active, returns
the original sections array by reference (memoised consumers don't
invalidate). When active, maps each section to a copy with its
panels[] filtered + reordered by panel_type.
- New lensFilteredChapterPanels useMemo — same treatment for the
flat chapter-panels list.
- useChapterPanels now receives lensFilteredSections; ChapterVerseList
receives both filtered versions; studyEstimate uses the filtered
panels so depth estimate reflects the lens view.
Behaviour decisions (pinned in tests):
- Empty filter array → treat as 'no filter' (don't render zero panels)
- Order keys not in panels → silently dropped, no crash
- Panels not in order → appended after ordered keys, preserving
original relative order
- Bad JSON → logger.warn, fallback to no transformation
Out of scope:
- Reordering panels within a section's content (we operate at the
panel-key level only)
- New lens content (Phase 2 #1781 Genesis pilot delivers that)
Closes#1780.
@github-actions

github-actionsBot commented Apr 28, 2026

Copy link
Copy Markdown

Test Results

✅ All tests passed

PassedFailedTotal
Tests✅ 4001❌ 04001
Suites✅ 532❌ 0532

Coverage

StatementsBranchesFunctionsLines

⏱️ Duration: 97.7s

Two test cases in lensPanelFilter.test.ts asserted that warnSpy
(jest.spyOn(console, 'warn')) was called when parseLensJson hit
malformed input or non-string entries. They passed locally but
failed in CI with 'Received number of calls: 0'.
Root cause: the project's jest setup silences/replaces console.warn
globally, so a fresh spy registers zero calls regardless of whether
logger.warn fires. Other test files in the repo handle this by
mocking the logger module directly (see useAmicusChipContext.test.tsx,
ProgressRow.test.tsx, etc.).
Fix: follow the project convention.
- jest.mock('@/utils/logger', ...) at module top
- pull mockLogger via require() inside the suite
- replace beforeEach/afterEach spy/restore with jest.clearAllMocks()
- assert against mockLogger.warn instead of warnSpy
Test count and behaviour pins are unchanged (28 cases). Just the
mocking strategy is corrected.
@CraigBuckmaster

Copy link
Copy Markdown
OwnerAuthor

CI fix — test job failure resolved

Two lensPanelFilter.test.ts cases asserted warnSpy (jest.spyOn(console, 'warn')) was called. They passed locally but failed in CI with Received number of calls: 0.

Root cause: the project's jest setup silences/replaces console.warn globally, so a fresh spy registers zero calls regardless of whether logger.warn fires.

Fix: follow the established project pattern — mock the logger module directly. Other test files in the repo do this exact thing (e.g. useAmicusChipContext.test.tsx, ProgressRow.test.tsx, all the db/*.test.ts files).

-let warnSpy: jest.SpyInstance;-beforeEach(() => {- warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);-});+jest.mock('@/utils/logger', () => ({+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },+}));+const { logger: mockLogger } = require('@/utils/logger') as { ... };+beforeEach(() => { jest.clearAllMocks(); });

Test count + behaviour pins unchanged (28 cases). a0530b8 pushed; CI re-running.

@CraigBuckmaster
CraigBuckmaster merged commit 8d770ec into masterApr 28, 2026
6 checks passed
@CraigBuckmaster
CraigBuckmaster deleted the feat/820-1-lens-panel-wiring branch April 28, 2026 18:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[#820 / 1] Wire panel_filter_json / panel_order_json in ChapterScreen

1 participant

@CraigBuckmaster
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) by CraigBuckmaster · Pull Request #1790 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) - #1790

Merged
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring
Apr 28, 2026
Merged

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780)#1790
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring

Conversation

@CraigBuckmaster

Copy link
Copy Markdown
Owner

Phase 1 of Epic #820 — wire panel_filter / panel_order in ChapterScreen

Closes#1780.

With Phase 0 (PR #1779) the schema, content directory, and loader for chapter_lens_content.panel_filter_json / panel_order_json shipped — but ChapterScreen.tsx consumed only lensContent.guidance, leaving the filter/order JSON columns dead. This PR makes them actually do something.

End user impact: when a chapter has lens content with a panel_filter or panel_order, activating that lens now hides non-listed panels and reorders the matching ones. The "Default" toggle restores the full view.

Utility (new)

app/src/utils/lensPanelFilter.ts — four pure functions:

FunctionBehaviour
parseLensJson(json)JSON.parse with logger.warn on failure; returns undefined for missing / malformed / non-array / non-string-array inputs
filterPanelKeys(keys, filter)Empty filter is treated as no filter (avoids rendering zero panels from an authoring bug). Original key order preserved.
orderPanelKeys(keys, order)Keys in order come first; keys not in order appended in original relative order; missing-from-actual-keys entries silently dropped
applyLensToKeys(keys, filter, order)Convenience: filter then order in one pass

Tests (new)

app/__tests__/unit/lensPanelFilter.test.ts28 cases covering:

  • parseLensJson: undefined/null/empty → undefined; valid array → strings; empty array; malformed JSON → undefined + warn; non-array JSON → undefined; non-string entries dropped + warn
  • filterPanelKeys: undefined filter; empty filter (deliberate no-op); kept-keys preserve original order; missing-from-keys filter entries dropped; no-overlap → []; input not mutated
  • orderPanelKeys: undefined/empty order; full reorder; partial order with appended unordered keys; missing-from-keys order entries dropped; duplicate order entries deduped; input not mutated
  • applyLensToKeys: filter+order combined; only filter; only order; order keys outside filter dropped
  • End-to-end: parsed JSON strings → applied; both malformed → keys unchanged

Wiring in ChapterScreen

The lens-data hooks (useAvailableLenses, useChapterLensContent) were previously fetched afteruseChapterPanels. Moved them earlier so derived filtered sections / chapterPanels can flow into the panel hook.

  • lensPanelFilter / lensPanelOrderuseMemos wrapping parseLensJson
  • lensIsActive — true when a lens is selected AND has filter or order data
  • lensFilteredSectionsuseMemo. When inactive, returns sections by reference (memoised consumers don't invalidate). When active, each section gets a copy with its panels[] filtered + reordered by panel_type.
  • lensFilteredChapterPanels — same treatment for the flat chapter-panels list

Downstream changes:

  • useChapterPanels({ sections: lensFilteredSections, ... }) — depth-tracking and panel-open handlers see the lens view
  • ChapterVerseList receives both filtered versions
  • studyEstimate uses the filtered panels so the chapter's depth estimate reflects the lens-trimmed view

What's NOT changed

  • useChapterTTS still receives the raw sections array — TTS reads verse text, not panels, so the lens filter has no relevance there.
  • The destructured sections and chapterPanels from useChapterData remain raw. They're the source of truth; lens filtering is a presentation transform only.

Behaviour decisions (pinned in tests)

  • Empty filter array → treat as "no filter". An empty array would render zero panels, which is almost certainly an authoring bug rather than intent. The schema validator already rejects empty panel_filter arrays at content time; this is the runtime safety net.
  • Order keys not present in panels → silently dropped, no crash
  • Panels not present in order → appended after ordered keys, preserving original relative order
  • Bad JSON in either column → logger.warn once, fallback to no transformation

Out of scope

Rollback plan

Pure additive refactor. The behaviour when no lens is active is byte-identical to master (verified by referential equality in the useMemo short-circuits). git revert is clean — nothing depends on these new helpers yet outside of the screen itself.

Note on bookkeeping

This issue was briefly closed on a false premise (I claimed PR #1779 contained this work; it didn't — see correction comment on #1780). Reopened, repeated, and shipping properly here.

Phase 1 of Epic #820. With Phase 0 the schema, content directory, and
loader for chapter_lens_content.panel_filter_json / panel_order_json
shipped, but ChapterScreen consumed only lensContent.guidance — the
filter/order JSON columns were dead. This PR makes them actually do
something.
Utility (new):
- app/src/utils/lensPanelFilter.ts — pure functions:
* parseLensJson(json) — JSON.parse with logger.warn on failure,
returns undefined for missing/malformed/non-array/non-string
* filterPanelKeys(keys, filter) — empty filter == no filter (avoids
rendering zero panels from an authoring bug)
* orderPanelKeys(keys, order) — keys in order first, then unordered
keys appended in original relative order; missing-from-order keys
silently dropped
* applyLensToKeys(keys, filter, order) — convenience that filters
then orders in one pass
Tests (new):
- app/__tests__/unit/lensPanelFilter.test.ts — 28 cases covering
empty filter (deliberate behaviour), missing keys in order, both
fields together, malformed JSON, non-string entries, mutation
safety, and the parseLensJson + applyLensToKeys end-to-end flow.
Wiring in ChapterScreen:
- Lens-data hooks moved earlier in the render flow so derived
filtered sections / chapterPanels can flow into useChapterPanels.
- New lensFilteredSections useMemo — when no lens active, returns
the original sections array by reference (memoised consumers don't
invalidate). When active, maps each section to a copy with its
panels[] filtered + reordered by panel_type.
- New lensFilteredChapterPanels useMemo — same treatment for the
flat chapter-panels list.
- useChapterPanels now receives lensFilteredSections; ChapterVerseList
receives both filtered versions; studyEstimate uses the filtered
panels so depth estimate reflects the lens view.
Behaviour decisions (pinned in tests):
- Empty filter array → treat as 'no filter' (don't render zero panels)
- Order keys not in panels → silently dropped, no crash
- Panels not in order → appended after ordered keys, preserving
original relative order
- Bad JSON → logger.warn, fallback to no transformation
Out of scope:
- Reordering panels within a section's content (we operate at the
panel-key level only)
- New lens content (Phase 2 #1781 Genesis pilot delivers that)
Closes#1780.
@github-actions

github-actionsBot commented Apr 28, 2026

Copy link
Copy Markdown

Test Results

✅ All tests passed

PassedFailedTotal
Tests✅ 4001❌ 04001
Suites✅ 532❌ 0532

Coverage

StatementsBranchesFunctionsLines

⏱️ Duration: 97.7s

Two test cases in lensPanelFilter.test.ts asserted that warnSpy
(jest.spyOn(console, 'warn')) was called when parseLensJson hit
malformed input or non-string entries. They passed locally but
failed in CI with 'Received number of calls: 0'.
Root cause: the project's jest setup silences/replaces console.warn
globally, so a fresh spy registers zero calls regardless of whether
logger.warn fires. Other test files in the repo handle this by
mocking the logger module directly (see useAmicusChipContext.test.tsx,
ProgressRow.test.tsx, etc.).
Fix: follow the project convention.
- jest.mock('@/utils/logger', ...) at module top
- pull mockLogger via require() inside the suite
- replace beforeEach/afterEach spy/restore with jest.clearAllMocks()
- assert against mockLogger.warn instead of warnSpy
Test count and behaviour pins are unchanged (28 cases). Just the
mocking strategy is corrected.
@CraigBuckmaster

Copy link
Copy Markdown
OwnerAuthor

CI fix — test job failure resolved

Two lensPanelFilter.test.ts cases asserted warnSpy (jest.spyOn(console, 'warn')) was called. They passed locally but failed in CI with Received number of calls: 0.

Root cause: the project's jest setup silences/replaces console.warn globally, so a fresh spy registers zero calls regardless of whether logger.warn fires.

Fix: follow the established project pattern — mock the logger module directly. Other test files in the repo do this exact thing (e.g. useAmicusChipContext.test.tsx, ProgressRow.test.tsx, all the db/*.test.ts files).

-let warnSpy: jest.SpyInstance;-beforeEach(() => {- warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);-});+jest.mock('@/utils/logger', () => ({+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },+}));+const { logger: mockLogger } = require('@/utils/logger') as { ... };+beforeEach(() => { jest.clearAllMocks(); });

Test count + behaviour pins unchanged (28 cases). a0530b8 pushed; CI re-running.

@CraigBuckmaster
CraigBuckmaster merged commit 8d770ec into masterApr 28, 2026
6 checks passed
@CraigBuckmaster
CraigBuckmaster deleted the feat/820-1-lens-panel-wiring branch April 28, 2026 18:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[#820 / 1] Wire panel_filter_json / panel_order_json in ChapterScreen

1 participant

@CraigBuckmaster
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) by CraigBuckmaster · Pull Request #1790 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) - #1790

Merged
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring
Apr 28, 2026
Merged

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780)#1790
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring

Conversation

@CraigBuckmaster

Copy link
Copy Markdown
Owner

Phase 1 of Epic #820 — wire panel_filter / panel_order in ChapterScreen

Closes#1780.

With Phase 0 (PR #1779) the schema, content directory, and loader for chapter_lens_content.panel_filter_json / panel_order_json shipped — but ChapterScreen.tsx consumed only lensContent.guidance, leaving the filter/order JSON columns dead. This PR makes them actually do something.

End user impact: when a chapter has lens content with a panel_filter or panel_order, activating that lens now hides non-listed panels and reorders the matching ones. The "Default" toggle restores the full view.

Utility (new)

app/src/utils/lensPanelFilter.ts — four pure functions:

FunctionBehaviour
parseLensJson(json)JSON.parse with logger.warn on failure; returns undefined for missing / malformed / non-array / non-string-array inputs
filterPanelKeys(keys, filter)Empty filter is treated as no filter (avoids rendering zero panels from an authoring bug). Original key order preserved.
orderPanelKeys(keys, order)Keys in order come first; keys not in order appended in original relative order; missing-from-actual-keys entries silently dropped
applyLensToKeys(keys, filter, order)Convenience: filter then order in one pass

Tests (new)

app/__tests__/unit/lensPanelFilter.test.ts28 cases covering:

  • parseLensJson: undefined/null/empty → undefined; valid array → strings; empty array; malformed JSON → undefined + warn; non-array JSON → undefined; non-string entries dropped + warn
  • filterPanelKeys: undefined filter; empty filter (deliberate no-op); kept-keys preserve original order; missing-from-keys filter entries dropped; no-overlap → []; input not mutated
  • orderPanelKeys: undefined/empty order; full reorder; partial order with appended unordered keys; missing-from-keys order entries dropped; duplicate order entries deduped; input not mutated
  • applyLensToKeys: filter+order combined; only filter; only order; order keys outside filter dropped
  • End-to-end: parsed JSON strings → applied; both malformed → keys unchanged

Wiring in ChapterScreen

The lens-data hooks (useAvailableLenses, useChapterLensContent) were previously fetched afteruseChapterPanels. Moved them earlier so derived filtered sections / chapterPanels can flow into the panel hook.

  • lensPanelFilter / lensPanelOrderuseMemos wrapping parseLensJson
  • lensIsActive — true when a lens is selected AND has filter or order data
  • lensFilteredSectionsuseMemo. When inactive, returns sections by reference (memoised consumers don't invalidate). When active, each section gets a copy with its panels[] filtered + reordered by panel_type.
  • lensFilteredChapterPanels — same treatment for the flat chapter-panels list

Downstream changes:

  • useChapterPanels({ sections: lensFilteredSections, ... }) — depth-tracking and panel-open handlers see the lens view
  • ChapterVerseList receives both filtered versions
  • studyEstimate uses the filtered panels so the chapter's depth estimate reflects the lens-trimmed view

What's NOT changed

  • useChapterTTS still receives the raw sections array — TTS reads verse text, not panels, so the lens filter has no relevance there.
  • The destructured sections and chapterPanels from useChapterData remain raw. They're the source of truth; lens filtering is a presentation transform only.

Behaviour decisions (pinned in tests)

  • Empty filter array → treat as "no filter". An empty array would render zero panels, which is almost certainly an authoring bug rather than intent. The schema validator already rejects empty panel_filter arrays at content time; this is the runtime safety net.
  • Order keys not present in panels → silently dropped, no crash
  • Panels not present in order → appended after ordered keys, preserving original relative order
  • Bad JSON in either column → logger.warn once, fallback to no transformation

Out of scope

Rollback plan

Pure additive refactor. The behaviour when no lens is active is byte-identical to master (verified by referential equality in the useMemo short-circuits). git revert is clean — nothing depends on these new helpers yet outside of the screen itself.

Note on bookkeeping

This issue was briefly closed on a false premise (I claimed PR #1779 contained this work; it didn't — see correction comment on #1780). Reopened, repeated, and shipping properly here.

Phase 1 of Epic #820. With Phase 0 the schema, content directory, and
loader for chapter_lens_content.panel_filter_json / panel_order_json
shipped, but ChapterScreen consumed only lensContent.guidance — the
filter/order JSON columns were dead. This PR makes them actually do
something.
Utility (new):
- app/src/utils/lensPanelFilter.ts — pure functions:
* parseLensJson(json) — JSON.parse with logger.warn on failure,
returns undefined for missing/malformed/non-array/non-string
* filterPanelKeys(keys, filter) — empty filter == no filter (avoids
rendering zero panels from an authoring bug)
* orderPanelKeys(keys, order) — keys in order first, then unordered
keys appended in original relative order; missing-from-order keys
silently dropped
* applyLensToKeys(keys, filter, order) — convenience that filters
then orders in one pass
Tests (new):
- app/__tests__/unit/lensPanelFilter.test.ts — 28 cases covering
empty filter (deliberate behaviour), missing keys in order, both
fields together, malformed JSON, non-string entries, mutation
safety, and the parseLensJson + applyLensToKeys end-to-end flow.
Wiring in ChapterScreen:
- Lens-data hooks moved earlier in the render flow so derived
filtered sections / chapterPanels can flow into useChapterPanels.
- New lensFilteredSections useMemo — when no lens active, returns
the original sections array by reference (memoised consumers don't
invalidate). When active, maps each section to a copy with its
panels[] filtered + reordered by panel_type.
- New lensFilteredChapterPanels useMemo — same treatment for the
flat chapter-panels list.
- useChapterPanels now receives lensFilteredSections; ChapterVerseList
receives both filtered versions; studyEstimate uses the filtered
panels so depth estimate reflects the lens view.
Behaviour decisions (pinned in tests):
- Empty filter array → treat as 'no filter' (don't render zero panels)
- Order keys not in panels → silently dropped, no crash
- Panels not in order → appended after ordered keys, preserving
original relative order
- Bad JSON → logger.warn, fallback to no transformation
Out of scope:
- Reordering panels within a section's content (we operate at the
panel-key level only)
- New lens content (Phase 2 #1781 Genesis pilot delivers that)
Closes#1780.
@github-actions

github-actionsBot commented Apr 28, 2026

Copy link
Copy Markdown

Test Results

✅ All tests passed

PassedFailedTotal
Tests✅ 4001❌ 04001
Suites✅ 532❌ 0532

Coverage

StatementsBranchesFunctionsLines

⏱️ Duration: 97.7s

Two test cases in lensPanelFilter.test.ts asserted that warnSpy
(jest.spyOn(console, 'warn')) was called when parseLensJson hit
malformed input or non-string entries. They passed locally but
failed in CI with 'Received number of calls: 0'.
Root cause: the project's jest setup silences/replaces console.warn
globally, so a fresh spy registers zero calls regardless of whether
logger.warn fires. Other test files in the repo handle this by
mocking the logger module directly (see useAmicusChipContext.test.tsx,
ProgressRow.test.tsx, etc.).
Fix: follow the project convention.
- jest.mock('@/utils/logger', ...) at module top
- pull mockLogger via require() inside the suite
- replace beforeEach/afterEach spy/restore with jest.clearAllMocks()
- assert against mockLogger.warn instead of warnSpy
Test count and behaviour pins are unchanged (28 cases). Just the
mocking strategy is corrected.
@CraigBuckmaster

Copy link
Copy Markdown
OwnerAuthor

CI fix — test job failure resolved

Two lensPanelFilter.test.ts cases asserted warnSpy (jest.spyOn(console, 'warn')) was called. They passed locally but failed in CI with Received number of calls: 0.

Root cause: the project's jest setup silences/replaces console.warn globally, so a fresh spy registers zero calls regardless of whether logger.warn fires.

Fix: follow the established project pattern — mock the logger module directly. Other test files in the repo do this exact thing (e.g. useAmicusChipContext.test.tsx, ProgressRow.test.tsx, all the db/*.test.ts files).

-let warnSpy: jest.SpyInstance;-beforeEach(() => {- warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);-});+jest.mock('@/utils/logger', () => ({+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },+}));+const { logger: mockLogger } = require('@/utils/logger') as { ... };+beforeEach(() => { jest.clearAllMocks(); });

Test count + behaviour pins unchanged (28 cases). a0530b8 pushed; CI re-running.

@CraigBuckmaster
CraigBuckmaster merged commit 8d770ec into masterApr 28, 2026
6 checks passed
@CraigBuckmaster
CraigBuckmaster deleted the feat/820-1-lens-panel-wiring branch April 28, 2026 18:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[#820 / 1] Wire panel_filter_json / panel_order_json in ChapterScreen

1 participant

@CraigBuckmaster
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) by CraigBuckmaster · Pull Request #1790 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) - #1790

Merged
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring
Apr 28, 2026
Merged

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780)#1790
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring

Conversation

@CraigBuckmaster

Copy link
Copy Markdown
Owner

Phase 1 of Epic #820 — wire panel_filter / panel_order in ChapterScreen

Closes#1780.

With Phase 0 (PR #1779) the schema, content directory, and loader for chapter_lens_content.panel_filter_json / panel_order_json shipped — but ChapterScreen.tsx consumed only lensContent.guidance, leaving the filter/order JSON columns dead. This PR makes them actually do something.

End user impact: when a chapter has lens content with a panel_filter or panel_order, activating that lens now hides non-listed panels and reorders the matching ones. The "Default" toggle restores the full view.

Utility (new)

app/src/utils/lensPanelFilter.ts — four pure functions:

FunctionBehaviour
parseLensJson(json)JSON.parse with logger.warn on failure; returns undefined for missing / malformed / non-array / non-string-array inputs
filterPanelKeys(keys, filter)Empty filter is treated as no filter (avoids rendering zero panels from an authoring bug). Original key order preserved.
orderPanelKeys(keys, order)Keys in order come first; keys not in order appended in original relative order; missing-from-actual-keys entries silently dropped
applyLensToKeys(keys, filter, order)Convenience: filter then order in one pass

Tests (new)

app/__tests__/unit/lensPanelFilter.test.ts28 cases covering:

  • parseLensJson: undefined/null/empty → undefined; valid array → strings; empty array; malformed JSON → undefined + warn; non-array JSON → undefined; non-string entries dropped + warn
  • filterPanelKeys: undefined filter; empty filter (deliberate no-op); kept-keys preserve original order; missing-from-keys filter entries dropped; no-overlap → []; input not mutated
  • orderPanelKeys: undefined/empty order; full reorder; partial order with appended unordered keys; missing-from-keys order entries dropped; duplicate order entries deduped; input not mutated
  • applyLensToKeys: filter+order combined; only filter; only order; order keys outside filter dropped
  • End-to-end: parsed JSON strings → applied; both malformed → keys unchanged

Wiring in ChapterScreen

The lens-data hooks (useAvailableLenses, useChapterLensContent) were previously fetched afteruseChapterPanels. Moved them earlier so derived filtered sections / chapterPanels can flow into the panel hook.

  • lensPanelFilter / lensPanelOrderuseMemos wrapping parseLensJson
  • lensIsActive — true when a lens is selected AND has filter or order data
  • lensFilteredSectionsuseMemo. When inactive, returns sections by reference (memoised consumers don't invalidate). When active, each section gets a copy with its panels[] filtered + reordered by panel_type.
  • lensFilteredChapterPanels — same treatment for the flat chapter-panels list

Downstream changes:

  • useChapterPanels({ sections: lensFilteredSections, ... }) — depth-tracking and panel-open handlers see the lens view
  • ChapterVerseList receives both filtered versions
  • studyEstimate uses the filtered panels so the chapter's depth estimate reflects the lens-trimmed view

What's NOT changed

  • useChapterTTS still receives the raw sections array — TTS reads verse text, not panels, so the lens filter has no relevance there.
  • The destructured sections and chapterPanels from useChapterData remain raw. They're the source of truth; lens filtering is a presentation transform only.

Behaviour decisions (pinned in tests)

  • Empty filter array → treat as "no filter". An empty array would render zero panels, which is almost certainly an authoring bug rather than intent. The schema validator already rejects empty panel_filter arrays at content time; this is the runtime safety net.
  • Order keys not present in panels → silently dropped, no crash
  • Panels not present in order → appended after ordered keys, preserving original relative order
  • Bad JSON in either column → logger.warn once, fallback to no transformation

Out of scope

Rollback plan

Pure additive refactor. The behaviour when no lens is active is byte-identical to master (verified by referential equality in the useMemo short-circuits). git revert is clean — nothing depends on these new helpers yet outside of the screen itself.

Note on bookkeeping

This issue was briefly closed on a false premise (I claimed PR #1779 contained this work; it didn't — see correction comment on #1780). Reopened, repeated, and shipping properly here.

Phase 1 of Epic #820. With Phase 0 the schema, content directory, and
loader for chapter_lens_content.panel_filter_json / panel_order_json
shipped, but ChapterScreen consumed only lensContent.guidance — the
filter/order JSON columns were dead. This PR makes them actually do
something.
Utility (new):
- app/src/utils/lensPanelFilter.ts — pure functions:
* parseLensJson(json) — JSON.parse with logger.warn on failure,
returns undefined for missing/malformed/non-array/non-string
* filterPanelKeys(keys, filter) — empty filter == no filter (avoids
rendering zero panels from an authoring bug)
* orderPanelKeys(keys, order) — keys in order first, then unordered
keys appended in original relative order; missing-from-order keys
silently dropped
* applyLensToKeys(keys, filter, order) — convenience that filters
then orders in one pass
Tests (new):
- app/__tests__/unit/lensPanelFilter.test.ts — 28 cases covering
empty filter (deliberate behaviour), missing keys in order, both
fields together, malformed JSON, non-string entries, mutation
safety, and the parseLensJson + applyLensToKeys end-to-end flow.
Wiring in ChapterScreen:
- Lens-data hooks moved earlier in the render flow so derived
filtered sections / chapterPanels can flow into useChapterPanels.
- New lensFilteredSections useMemo — when no lens active, returns
the original sections array by reference (memoised consumers don't
invalidate). When active, maps each section to a copy with its
panels[] filtered + reordered by panel_type.
- New lensFilteredChapterPanels useMemo — same treatment for the
flat chapter-panels list.
- useChapterPanels now receives lensFilteredSections; ChapterVerseList
receives both filtered versions; studyEstimate uses the filtered
panels so depth estimate reflects the lens view.
Behaviour decisions (pinned in tests):
- Empty filter array → treat as 'no filter' (don't render zero panels)
- Order keys not in panels → silently dropped, no crash
- Panels not in order → appended after ordered keys, preserving
original relative order
- Bad JSON → logger.warn, fallback to no transformation
Out of scope:
- Reordering panels within a section's content (we operate at the
panel-key level only)
- New lens content (Phase 2 #1781 Genesis pilot delivers that)
Closes#1780.
@github-actions

github-actionsBot commented Apr 28, 2026

Copy link
Copy Markdown

Test Results

✅ All tests passed

PassedFailedTotal
Tests✅ 4001❌ 04001
Suites✅ 532❌ 0532

Coverage

StatementsBranchesFunctionsLines

⏱️ Duration: 97.7s

Two test cases in lensPanelFilter.test.ts asserted that warnSpy
(jest.spyOn(console, 'warn')) was called when parseLensJson hit
malformed input or non-string entries. They passed locally but
failed in CI with 'Received number of calls: 0'.
Root cause: the project's jest setup silences/replaces console.warn
globally, so a fresh spy registers zero calls regardless of whether
logger.warn fires. Other test files in the repo handle this by
mocking the logger module directly (see useAmicusChipContext.test.tsx,
ProgressRow.test.tsx, etc.).
Fix: follow the project convention.
- jest.mock('@/utils/logger', ...) at module top
- pull mockLogger via require() inside the suite
- replace beforeEach/afterEach spy/restore with jest.clearAllMocks()
- assert against mockLogger.warn instead of warnSpy
Test count and behaviour pins are unchanged (28 cases). Just the
mocking strategy is corrected.
@CraigBuckmaster

Copy link
Copy Markdown
OwnerAuthor

CI fix — test job failure resolved

Two lensPanelFilter.test.ts cases asserted warnSpy (jest.spyOn(console, 'warn')) was called. They passed locally but failed in CI with Received number of calls: 0.

Root cause: the project's jest setup silences/replaces console.warn globally, so a fresh spy registers zero calls regardless of whether logger.warn fires.

Fix: follow the established project pattern — mock the logger module directly. Other test files in the repo do this exact thing (e.g. useAmicusChipContext.test.tsx, ProgressRow.test.tsx, all the db/*.test.ts files).

-let warnSpy: jest.SpyInstance;-beforeEach(() => {- warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);-});+jest.mock('@/utils/logger', () => ({+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },+}));+const { logger: mockLogger } = require('@/utils/logger') as { ... };+beforeEach(() => { jest.clearAllMocks(); });

Test count + behaviour pins unchanged (28 cases). a0530b8 pushed; CI re-running.

@CraigBuckmaster
CraigBuckmaster merged commit 8d770ec into masterApr 28, 2026
6 checks passed
@CraigBuckmaster
CraigBuckmaster deleted the feat/820-1-lens-panel-wiring branch April 28, 2026 18:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[#820 / 1] Wire panel_filter_json / panel_order_json in ChapterScreen

1 participant

@CraigBuckmaster
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) by CraigBuckmaster · Pull Request #1790 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) - #1790

Merged
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring
Apr 28, 2026
Merged

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780)#1790
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring

Conversation

@CraigBuckmaster

Copy link
Copy Markdown
Owner

Phase 1 of Epic #820 — wire panel_filter / panel_order in ChapterScreen

Closes#1780.

With Phase 0 (PR #1779) the schema, content directory, and loader for chapter_lens_content.panel_filter_json / panel_order_json shipped — but ChapterScreen.tsx consumed only lensContent.guidance, leaving the filter/order JSON columns dead. This PR makes them actually do something.

End user impact: when a chapter has lens content with a panel_filter or panel_order, activating that lens now hides non-listed panels and reorders the matching ones. The "Default" toggle restores the full view.

Utility (new)

app/src/utils/lensPanelFilter.ts — four pure functions:

FunctionBehaviour
parseLensJson(json)JSON.parse with logger.warn on failure; returns undefined for missing / malformed / non-array / non-string-array inputs
filterPanelKeys(keys, filter)Empty filter is treated as no filter (avoids rendering zero panels from an authoring bug). Original key order preserved.
orderPanelKeys(keys, order)Keys in order come first; keys not in order appended in original relative order; missing-from-actual-keys entries silently dropped
applyLensToKeys(keys, filter, order)Convenience: filter then order in one pass

Tests (new)

app/__tests__/unit/lensPanelFilter.test.ts28 cases covering:

  • parseLensJson: undefined/null/empty → undefined; valid array → strings; empty array; malformed JSON → undefined + warn; non-array JSON → undefined; non-string entries dropped + warn
  • filterPanelKeys: undefined filter; empty filter (deliberate no-op); kept-keys preserve original order; missing-from-keys filter entries dropped; no-overlap → []; input not mutated
  • orderPanelKeys: undefined/empty order; full reorder; partial order with appended unordered keys; missing-from-keys order entries dropped; duplicate order entries deduped; input not mutated
  • applyLensToKeys: filter+order combined; only filter; only order; order keys outside filter dropped
  • End-to-end: parsed JSON strings → applied; both malformed → keys unchanged

Wiring in ChapterScreen

The lens-data hooks (useAvailableLenses, useChapterLensContent) were previously fetched afteruseChapterPanels. Moved them earlier so derived filtered sections / chapterPanels can flow into the panel hook.

  • lensPanelFilter / lensPanelOrderuseMemos wrapping parseLensJson
  • lensIsActive — true when a lens is selected AND has filter or order data
  • lensFilteredSectionsuseMemo. When inactive, returns sections by reference (memoised consumers don't invalidate). When active, each section gets a copy with its panels[] filtered + reordered by panel_type.
  • lensFilteredChapterPanels — same treatment for the flat chapter-panels list

Downstream changes:

  • useChapterPanels({ sections: lensFilteredSections, ... }) — depth-tracking and panel-open handlers see the lens view
  • ChapterVerseList receives both filtered versions
  • studyEstimate uses the filtered panels so the chapter's depth estimate reflects the lens-trimmed view

What's NOT changed

  • useChapterTTS still receives the raw sections array — TTS reads verse text, not panels, so the lens filter has no relevance there.
  • The destructured sections and chapterPanels from useChapterData remain raw. They're the source of truth; lens filtering is a presentation transform only.

Behaviour decisions (pinned in tests)

  • Empty filter array → treat as "no filter". An empty array would render zero panels, which is almost certainly an authoring bug rather than intent. The schema validator already rejects empty panel_filter arrays at content time; this is the runtime safety net.
  • Order keys not present in panels → silently dropped, no crash
  • Panels not present in order → appended after ordered keys, preserving original relative order
  • Bad JSON in either column → logger.warn once, fallback to no transformation

Out of scope

Rollback plan

Pure additive refactor. The behaviour when no lens is active is byte-identical to master (verified by referential equality in the useMemo short-circuits). git revert is clean — nothing depends on these new helpers yet outside of the screen itself.

Note on bookkeeping

This issue was briefly closed on a false premise (I claimed PR #1779 contained this work; it didn't — see correction comment on #1780). Reopened, repeated, and shipping properly here.

Phase 1 of Epic #820. With Phase 0 the schema, content directory, and
loader for chapter_lens_content.panel_filter_json / panel_order_json
shipped, but ChapterScreen consumed only lensContent.guidance — the
filter/order JSON columns were dead. This PR makes them actually do
something.
Utility (new):
- app/src/utils/lensPanelFilter.ts — pure functions:
* parseLensJson(json) — JSON.parse with logger.warn on failure,
returns undefined for missing/malformed/non-array/non-string
* filterPanelKeys(keys, filter) — empty filter == no filter (avoids
rendering zero panels from an authoring bug)
* orderPanelKeys(keys, order) — keys in order first, then unordered
keys appended in original relative order; missing-from-order keys
silently dropped
* applyLensToKeys(keys, filter, order) — convenience that filters
then orders in one pass
Tests (new):
- app/__tests__/unit/lensPanelFilter.test.ts — 28 cases covering
empty filter (deliberate behaviour), missing keys in order, both
fields together, malformed JSON, non-string entries, mutation
safety, and the parseLensJson + applyLensToKeys end-to-end flow.
Wiring in ChapterScreen:
- Lens-data hooks moved earlier in the render flow so derived
filtered sections / chapterPanels can flow into useChapterPanels.
- New lensFilteredSections useMemo — when no lens active, returns
the original sections array by reference (memoised consumers don't
invalidate). When active, maps each section to a copy with its
panels[] filtered + reordered by panel_type.
- New lensFilteredChapterPanels useMemo — same treatment for the
flat chapter-panels list.
- useChapterPanels now receives lensFilteredSections; ChapterVerseList
receives both filtered versions; studyEstimate uses the filtered
panels so depth estimate reflects the lens view.
Behaviour decisions (pinned in tests):
- Empty filter array → treat as 'no filter' (don't render zero panels)
- Order keys not in panels → silently dropped, no crash
- Panels not in order → appended after ordered keys, preserving
original relative order
- Bad JSON → logger.warn, fallback to no transformation
Out of scope:
- Reordering panels within a section's content (we operate at the
panel-key level only)
- New lens content (Phase 2 #1781 Genesis pilot delivers that)
Closes#1780.
@github-actions

github-actionsBot commented Apr 28, 2026

Copy link
Copy Markdown

Test Results

✅ All tests passed

PassedFailedTotal
Tests✅ 4001❌ 04001
Suites✅ 532❌ 0532

Coverage

StatementsBranchesFunctionsLines

⏱️ Duration: 97.7s

Two test cases in lensPanelFilter.test.ts asserted that warnSpy
(jest.spyOn(console, 'warn')) was called when parseLensJson hit
malformed input or non-string entries. They passed locally but
failed in CI with 'Received number of calls: 0'.
Root cause: the project's jest setup silences/replaces console.warn
globally, so a fresh spy registers zero calls regardless of whether
logger.warn fires. Other test files in the repo handle this by
mocking the logger module directly (see useAmicusChipContext.test.tsx,
ProgressRow.test.tsx, etc.).
Fix: follow the project convention.
- jest.mock('@/utils/logger', ...) at module top
- pull mockLogger via require() inside the suite
- replace beforeEach/afterEach spy/restore with jest.clearAllMocks()
- assert against mockLogger.warn instead of warnSpy
Test count and behaviour pins are unchanged (28 cases). Just the
mocking strategy is corrected.
@CraigBuckmaster

Copy link
Copy Markdown
OwnerAuthor

CI fix — test job failure resolved

Two lensPanelFilter.test.ts cases asserted warnSpy (jest.spyOn(console, 'warn')) was called. They passed locally but failed in CI with Received number of calls: 0.

Root cause: the project's jest setup silences/replaces console.warn globally, so a fresh spy registers zero calls regardless of whether logger.warn fires.

Fix: follow the established project pattern — mock the logger module directly. Other test files in the repo do this exact thing (e.g. useAmicusChipContext.test.tsx, ProgressRow.test.tsx, all the db/*.test.ts files).

-let warnSpy: jest.SpyInstance;-beforeEach(() => {- warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);-});+jest.mock('@/utils/logger', () => ({+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },+}));+const { logger: mockLogger } = require('@/utils/logger') as { ... };+beforeEach(() => { jest.clearAllMocks(); });

Test count + behaviour pins unchanged (28 cases). a0530b8 pushed; CI re-running.

@CraigBuckmaster
CraigBuckmaster merged commit 8d770ec into masterApr 28, 2026
6 checks passed
@CraigBuckmaster
CraigBuckmaster deleted the feat/820-1-lens-panel-wiring branch April 28, 2026 18:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[#820 / 1] Wire panel_filter_json / panel_order_json in ChapterScreen

1 participant

@CraigBuckmaster
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) by CraigBuckmaster · Pull Request #1790 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) - #1790

Merged
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring
Apr 28, 2026
Merged

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780)#1790
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring

Conversation

@CraigBuckmaster

Copy link
Copy Markdown
Owner

Phase 1 of Epic #820 — wire panel_filter / panel_order in ChapterScreen

Closes#1780.

With Phase 0 (PR #1779) the schema, content directory, and loader for chapter_lens_content.panel_filter_json / panel_order_json shipped — but ChapterScreen.tsx consumed only lensContent.guidance, leaving the filter/order JSON columns dead. This PR makes them actually do something.

End user impact: when a chapter has lens content with a panel_filter or panel_order, activating that lens now hides non-listed panels and reorders the matching ones. The "Default" toggle restores the full view.

Utility (new)

app/src/utils/lensPanelFilter.ts — four pure functions:

FunctionBehaviour
parseLensJson(json)JSON.parse with logger.warn on failure; returns undefined for missing / malformed / non-array / non-string-array inputs
filterPanelKeys(keys, filter)Empty filter is treated as no filter (avoids rendering zero panels from an authoring bug). Original key order preserved.
orderPanelKeys(keys, order)Keys in order come first; keys not in order appended in original relative order; missing-from-actual-keys entries silently dropped
applyLensToKeys(keys, filter, order)Convenience: filter then order in one pass

Tests (new)

app/__tests__/unit/lensPanelFilter.test.ts28 cases covering:

  • parseLensJson: undefined/null/empty → undefined; valid array → strings; empty array; malformed JSON → undefined + warn; non-array JSON → undefined; non-string entries dropped + warn
  • filterPanelKeys: undefined filter; empty filter (deliberate no-op); kept-keys preserve original order; missing-from-keys filter entries dropped; no-overlap → []; input not mutated
  • orderPanelKeys: undefined/empty order; full reorder; partial order with appended unordered keys; missing-from-keys order entries dropped; duplicate order entries deduped; input not mutated
  • applyLensToKeys: filter+order combined; only filter; only order; order keys outside filter dropped
  • End-to-end: parsed JSON strings → applied; both malformed → keys unchanged

Wiring in ChapterScreen

The lens-data hooks (useAvailableLenses, useChapterLensContent) were previously fetched afteruseChapterPanels. Moved them earlier so derived filtered sections / chapterPanels can flow into the panel hook.

  • lensPanelFilter / lensPanelOrderuseMemos wrapping parseLensJson
  • lensIsActive — true when a lens is selected AND has filter or order data
  • lensFilteredSectionsuseMemo. When inactive, returns sections by reference (memoised consumers don't invalidate). When active, each section gets a copy with its panels[] filtered + reordered by panel_type.
  • lensFilteredChapterPanels — same treatment for the flat chapter-panels list

Downstream changes:

  • useChapterPanels({ sections: lensFilteredSections, ... }) — depth-tracking and panel-open handlers see the lens view
  • ChapterVerseList receives both filtered versions
  • studyEstimate uses the filtered panels so the chapter's depth estimate reflects the lens-trimmed view

What's NOT changed

  • useChapterTTS still receives the raw sections array — TTS reads verse text, not panels, so the lens filter has no relevance there.
  • The destructured sections and chapterPanels from useChapterData remain raw. They're the source of truth; lens filtering is a presentation transform only.

Behaviour decisions (pinned in tests)

  • Empty filter array → treat as "no filter". An empty array would render zero panels, which is almost certainly an authoring bug rather than intent. The schema validator already rejects empty panel_filter arrays at content time; this is the runtime safety net.
  • Order keys not present in panels → silently dropped, no crash
  • Panels not present in order → appended after ordered keys, preserving original relative order
  • Bad JSON in either column → logger.warn once, fallback to no transformation

Out of scope

Rollback plan

Pure additive refactor. The behaviour when no lens is active is byte-identical to master (verified by referential equality in the useMemo short-circuits). git revert is clean — nothing depends on these new helpers yet outside of the screen itself.

Note on bookkeeping

This issue was briefly closed on a false premise (I claimed PR #1779 contained this work; it didn't — see correction comment on #1780). Reopened, repeated, and shipping properly here.

Phase 1 of Epic #820. With Phase 0 the schema, content directory, and
loader for chapter_lens_content.panel_filter_json / panel_order_json
shipped, but ChapterScreen consumed only lensContent.guidance — the
filter/order JSON columns were dead. This PR makes them actually do
something.
Utility (new):
- app/src/utils/lensPanelFilter.ts — pure functions:
* parseLensJson(json) — JSON.parse with logger.warn on failure,
returns undefined for missing/malformed/non-array/non-string
* filterPanelKeys(keys, filter) — empty filter == no filter (avoids
rendering zero panels from an authoring bug)
* orderPanelKeys(keys, order) — keys in order first, then unordered
keys appended in original relative order; missing-from-order keys
silently dropped
* applyLensToKeys(keys, filter, order) — convenience that filters
then orders in one pass
Tests (new):
- app/__tests__/unit/lensPanelFilter.test.ts — 28 cases covering
empty filter (deliberate behaviour), missing keys in order, both
fields together, malformed JSON, non-string entries, mutation
safety, and the parseLensJson + applyLensToKeys end-to-end flow.
Wiring in ChapterScreen:
- Lens-data hooks moved earlier in the render flow so derived
filtered sections / chapterPanels can flow into useChapterPanels.
- New lensFilteredSections useMemo — when no lens active, returns
the original sections array by reference (memoised consumers don't
invalidate). When active, maps each section to a copy with its
panels[] filtered + reordered by panel_type.
- New lensFilteredChapterPanels useMemo — same treatment for the
flat chapter-panels list.
- useChapterPanels now receives lensFilteredSections; ChapterVerseList
receives both filtered versions; studyEstimate uses the filtered
panels so depth estimate reflects the lens view.
Behaviour decisions (pinned in tests):
- Empty filter array → treat as 'no filter' (don't render zero panels)
- Order keys not in panels → silently dropped, no crash
- Panels not in order → appended after ordered keys, preserving
original relative order
- Bad JSON → logger.warn, fallback to no transformation
Out of scope:
- Reordering panels within a section's content (we operate at the
panel-key level only)
- New lens content (Phase 2 #1781 Genesis pilot delivers that)
Closes#1780.
@github-actions

github-actionsBot commented Apr 28, 2026

Copy link
Copy Markdown

Test Results

✅ All tests passed

PassedFailedTotal
Tests✅ 4001❌ 04001
Suites✅ 532❌ 0532

Coverage

StatementsBranchesFunctionsLines

⏱️ Duration: 97.7s

Two test cases in lensPanelFilter.test.ts asserted that warnSpy
(jest.spyOn(console, 'warn')) was called when parseLensJson hit
malformed input or non-string entries. They passed locally but
failed in CI with 'Received number of calls: 0'.
Root cause: the project's jest setup silences/replaces console.warn
globally, so a fresh spy registers zero calls regardless of whether
logger.warn fires. Other test files in the repo handle this by
mocking the logger module directly (see useAmicusChipContext.test.tsx,
ProgressRow.test.tsx, etc.).
Fix: follow the project convention.
- jest.mock('@/utils/logger', ...) at module top
- pull mockLogger via require() inside the suite
- replace beforeEach/afterEach spy/restore with jest.clearAllMocks()
- assert against mockLogger.warn instead of warnSpy
Test count and behaviour pins are unchanged (28 cases). Just the
mocking strategy is corrected.
@CraigBuckmaster

Copy link
Copy Markdown
OwnerAuthor

CI fix — test job failure resolved

Two lensPanelFilter.test.ts cases asserted warnSpy (jest.spyOn(console, 'warn')) was called. They passed locally but failed in CI with Received number of calls: 0.

Root cause: the project's jest setup silences/replaces console.warn globally, so a fresh spy registers zero calls regardless of whether logger.warn fires.

Fix: follow the established project pattern — mock the logger module directly. Other test files in the repo do this exact thing (e.g. useAmicusChipContext.test.tsx, ProgressRow.test.tsx, all the db/*.test.ts files).

-let warnSpy: jest.SpyInstance;-beforeEach(() => {- warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);-});+jest.mock('@/utils/logger', () => ({+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },+}));+const { logger: mockLogger } = require('@/utils/logger') as { ... };+beforeEach(() => { jest.clearAllMocks(); });

Test count + behaviour pins unchanged (28 cases). a0530b8 pushed; CI re-running.

@CraigBuckmaster
CraigBuckmaster merged commit 8d770ec into masterApr 28, 2026
6 checks passed
@CraigBuckmaster
CraigBuckmaster deleted the feat/820-1-lens-panel-wiring branch April 28, 2026 18:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[#820 / 1] Wire panel_filter_json / panel_order_json in ChapterScreen

1 participant

@CraigBuckmaster
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) by CraigBuckmaster · Pull Request #1790 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) - #1790

Merged
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring
Apr 28, 2026
Merged

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780)#1790
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring

Conversation

@CraigBuckmaster

Copy link
Copy Markdown
Owner

Phase 1 of Epic #820 — wire panel_filter / panel_order in ChapterScreen

Closes#1780.

With Phase 0 (PR #1779) the schema, content directory, and loader for chapter_lens_content.panel_filter_json / panel_order_json shipped — but ChapterScreen.tsx consumed only lensContent.guidance, leaving the filter/order JSON columns dead. This PR makes them actually do something.

End user impact: when a chapter has lens content with a panel_filter or panel_order, activating that lens now hides non-listed panels and reorders the matching ones. The "Default" toggle restores the full view.

Utility (new)

app/src/utils/lensPanelFilter.ts — four pure functions:

FunctionBehaviour
parseLensJson(json)JSON.parse with logger.warn on failure; returns undefined for missing / malformed / non-array / non-string-array inputs
filterPanelKeys(keys, filter)Empty filter is treated as no filter (avoids rendering zero panels from an authoring bug). Original key order preserved.
orderPanelKeys(keys, order)Keys in order come first; keys not in order appended in original relative order; missing-from-actual-keys entries silently dropped
applyLensToKeys(keys, filter, order)Convenience: filter then order in one pass

Tests (new)

app/__tests__/unit/lensPanelFilter.test.ts28 cases covering:

  • parseLensJson: undefined/null/empty → undefined; valid array → strings; empty array; malformed JSON → undefined + warn; non-array JSON → undefined; non-string entries dropped + warn
  • filterPanelKeys: undefined filter; empty filter (deliberate no-op); kept-keys preserve original order; missing-from-keys filter entries dropped; no-overlap → []; input not mutated
  • orderPanelKeys: undefined/empty order; full reorder; partial order with appended unordered keys; missing-from-keys order entries dropped; duplicate order entries deduped; input not mutated
  • applyLensToKeys: filter+order combined; only filter; only order; order keys outside filter dropped
  • End-to-end: parsed JSON strings → applied; both malformed → keys unchanged

Wiring in ChapterScreen

The lens-data hooks (useAvailableLenses, useChapterLensContent) were previously fetched afteruseChapterPanels. Moved them earlier so derived filtered sections / chapterPanels can flow into the panel hook.

  • lensPanelFilter / lensPanelOrderuseMemos wrapping parseLensJson
  • lensIsActive — true when a lens is selected AND has filter or order data
  • lensFilteredSectionsuseMemo. When inactive, returns sections by reference (memoised consumers don't invalidate). When active, each section gets a copy with its panels[] filtered + reordered by panel_type.
  • lensFilteredChapterPanels — same treatment for the flat chapter-panels list

Downstream changes:

  • useChapterPanels({ sections: lensFilteredSections, ... }) — depth-tracking and panel-open handlers see the lens view
  • ChapterVerseList receives both filtered versions
  • studyEstimate uses the filtered panels so the chapter's depth estimate reflects the lens-trimmed view

What's NOT changed

  • useChapterTTS still receives the raw sections array — TTS reads verse text, not panels, so the lens filter has no relevance there.
  • The destructured sections and chapterPanels from useChapterData remain raw. They're the source of truth; lens filtering is a presentation transform only.

Behaviour decisions (pinned in tests)

  • Empty filter array → treat as "no filter". An empty array would render zero panels, which is almost certainly an authoring bug rather than intent. The schema validator already rejects empty panel_filter arrays at content time; this is the runtime safety net.
  • Order keys not present in panels → silently dropped, no crash
  • Panels not present in order → appended after ordered keys, preserving original relative order
  • Bad JSON in either column → logger.warn once, fallback to no transformation

Out of scope

Rollback plan

Pure additive refactor. The behaviour when no lens is active is byte-identical to master (verified by referential equality in the useMemo short-circuits). git revert is clean — nothing depends on these new helpers yet outside of the screen itself.

Note on bookkeeping

This issue was briefly closed on a false premise (I claimed PR #1779 contained this work; it didn't — see correction comment on #1780). Reopened, repeated, and shipping properly here.

Phase 1 of Epic #820. With Phase 0 the schema, content directory, and
loader for chapter_lens_content.panel_filter_json / panel_order_json
shipped, but ChapterScreen consumed only lensContent.guidance — the
filter/order JSON columns were dead. This PR makes them actually do
something.
Utility (new):
- app/src/utils/lensPanelFilter.ts — pure functions:
* parseLensJson(json) — JSON.parse with logger.warn on failure,
returns undefined for missing/malformed/non-array/non-string
* filterPanelKeys(keys, filter) — empty filter == no filter (avoids
rendering zero panels from an authoring bug)
* orderPanelKeys(keys, order) — keys in order first, then unordered
keys appended in original relative order; missing-from-order keys
silently dropped
* applyLensToKeys(keys, filter, order) — convenience that filters
then orders in one pass
Tests (new):
- app/__tests__/unit/lensPanelFilter.test.ts — 28 cases covering
empty filter (deliberate behaviour), missing keys in order, both
fields together, malformed JSON, non-string entries, mutation
safety, and the parseLensJson + applyLensToKeys end-to-end flow.
Wiring in ChapterScreen:
- Lens-data hooks moved earlier in the render flow so derived
filtered sections / chapterPanels can flow into useChapterPanels.
- New lensFilteredSections useMemo — when no lens active, returns
the original sections array by reference (memoised consumers don't
invalidate). When active, maps each section to a copy with its
panels[] filtered + reordered by panel_type.
- New lensFilteredChapterPanels useMemo — same treatment for the
flat chapter-panels list.
- useChapterPanels now receives lensFilteredSections; ChapterVerseList
receives both filtered versions; studyEstimate uses the filtered
panels so depth estimate reflects the lens view.
Behaviour decisions (pinned in tests):
- Empty filter array → treat as 'no filter' (don't render zero panels)
- Order keys not in panels → silently dropped, no crash
- Panels not in order → appended after ordered keys, preserving
original relative order
- Bad JSON → logger.warn, fallback to no transformation
Out of scope:
- Reordering panels within a section's content (we operate at the
panel-key level only)
- New lens content (Phase 2 #1781 Genesis pilot delivers that)
Closes#1780.
@github-actions

github-actionsBot commented Apr 28, 2026

Copy link
Copy Markdown

Test Results

✅ All tests passed

PassedFailedTotal
Tests✅ 4001❌ 04001
Suites✅ 532❌ 0532

Coverage

StatementsBranchesFunctionsLines

⏱️ Duration: 97.7s

Two test cases in lensPanelFilter.test.ts asserted that warnSpy
(jest.spyOn(console, 'warn')) was called when parseLensJson hit
malformed input or non-string entries. They passed locally but
failed in CI with 'Received number of calls: 0'.
Root cause: the project's jest setup silences/replaces console.warn
globally, so a fresh spy registers zero calls regardless of whether
logger.warn fires. Other test files in the repo handle this by
mocking the logger module directly (see useAmicusChipContext.test.tsx,
ProgressRow.test.tsx, etc.).
Fix: follow the project convention.
- jest.mock('@/utils/logger', ...) at module top
- pull mockLogger via require() inside the suite
- replace beforeEach/afterEach spy/restore with jest.clearAllMocks()
- assert against mockLogger.warn instead of warnSpy
Test count and behaviour pins are unchanged (28 cases). Just the
mocking strategy is corrected.
@CraigBuckmaster

Copy link
Copy Markdown
OwnerAuthor

CI fix — test job failure resolved

Two lensPanelFilter.test.ts cases asserted warnSpy (jest.spyOn(console, 'warn')) was called. They passed locally but failed in CI with Received number of calls: 0.

Root cause: the project's jest setup silences/replaces console.warn globally, so a fresh spy registers zero calls regardless of whether logger.warn fires.

Fix: follow the established project pattern — mock the logger module directly. Other test files in the repo do this exact thing (e.g. useAmicusChipContext.test.tsx, ProgressRow.test.tsx, all the db/*.test.ts files).

-let warnSpy: jest.SpyInstance;-beforeEach(() => {- warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);-});+jest.mock('@/utils/logger', () => ({+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },+}));+const { logger: mockLogger } = require('@/utils/logger') as { ... };+beforeEach(() => { jest.clearAllMocks(); });

Test count + behaviour pins unchanged (28 cases). a0530b8 pushed; CI re-running.

@CraigBuckmaster
CraigBuckmaster merged commit 8d770ec into masterApr 28, 2026
6 checks passed
@CraigBuckmaster
CraigBuckmaster deleted the feat/820-1-lens-panel-wiring branch April 28, 2026 18:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[#820 / 1] Wire panel_filter_json / panel_order_json in ChapterScreen

1 participant

@CraigBuckmaster
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) by CraigBuckmaster · Pull Request #1790 · CraigBuckmaster/ScriptureDeepDive · GitHub
Skip to content

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780) - #1790

Merged
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring
Apr 28, 2026
Merged

feat(lenses): wire panel_filter / panel_order in ChapterScreen (#1780)#1790
CraigBuckmaster merged 2 commits into
masterfrom
feat/820-1-lens-panel-wiring

Conversation

@CraigBuckmaster

Copy link
Copy Markdown
Owner

Phase 1 of Epic #820 — wire panel_filter / panel_order in ChapterScreen

Closes#1780.

With Phase 0 (PR #1779) the schema, content directory, and loader for chapter_lens_content.panel_filter_json / panel_order_json shipped — but ChapterScreen.tsx consumed only lensContent.guidance, leaving the filter/order JSON columns dead. This PR makes them actually do something.

End user impact: when a chapter has lens content with a panel_filter or panel_order, activating that lens now hides non-listed panels and reorders the matching ones. The "Default" toggle restores the full view.

Utility (new)

app/src/utils/lensPanelFilter.ts — four pure functions:

FunctionBehaviour
parseLensJson(json)JSON.parse with logger.warn on failure; returns undefined for missing / malformed / non-array / non-string-array inputs
filterPanelKeys(keys, filter)Empty filter is treated as no filter (avoids rendering zero panels from an authoring bug). Original key order preserved.
orderPanelKeys(keys, order)Keys in order come first; keys not in order appended in original relative order; missing-from-actual-keys entries silently dropped
applyLensToKeys(keys, filter, order)Convenience: filter then order in one pass

Tests (new)

app/__tests__/unit/lensPanelFilter.test.ts28 cases covering:

  • parseLensJson: undefined/null/empty → undefined; valid array → strings; empty array; malformed JSON → undefined + warn; non-array JSON → undefined; non-string entries dropped + warn
  • filterPanelKeys: undefined filter; empty filter (deliberate no-op); kept-keys preserve original order; missing-from-keys filter entries dropped; no-overlap → []; input not mutated
  • orderPanelKeys: undefined/empty order; full reorder; partial order with appended unordered keys; missing-from-keys order entries dropped; duplicate order entries deduped; input not mutated
  • applyLensToKeys: filter+order combined; only filter; only order; order keys outside filter dropped
  • End-to-end: parsed JSON strings → applied; both malformed → keys unchanged

Wiring in ChapterScreen

The lens-data hooks (useAvailableLenses, useChapterLensContent) were previously fetched afteruseChapterPanels. Moved them earlier so derived filtered sections / chapterPanels can flow into the panel hook.

  • lensPanelFilter / lensPanelOrderuseMemos wrapping parseLensJson
  • lensIsActive — true when a lens is selected AND has filter or order data
  • lensFilteredSectionsuseMemo. When inactive, returns sections by reference (memoised consumers don't invalidate). When active, each section gets a copy with its panels[] filtered + reordered by panel_type.
  • lensFilteredChapterPanels — same treatment for the flat chapter-panels list

Downstream changes:

  • useChapterPanels({ sections: lensFilteredSections, ... }) — depth-tracking and panel-open handlers see the lens view
  • ChapterVerseList receives both filtered versions
  • studyEstimate uses the filtered panels so the chapter's depth estimate reflects the lens-trimmed view

What's NOT changed

  • useChapterTTS still receives the raw sections array — TTS reads verse text, not panels, so the lens filter has no relevance there.
  • The destructured sections and chapterPanels from useChapterData remain raw. They're the source of truth; lens filtering is a presentation transform only.

Behaviour decisions (pinned in tests)

  • Empty filter array → treat as "no filter". An empty array would render zero panels, which is almost certainly an authoring bug rather than intent. The schema validator already rejects empty panel_filter arrays at content time; this is the runtime safety net.
  • Order keys not present in panels → silently dropped, no crash
  • Panels not present in order → appended after ordered keys, preserving original relative order
  • Bad JSON in either column → logger.warn once, fallback to no transformation

Out of scope

Rollback plan

Pure additive refactor. The behaviour when no lens is active is byte-identical to master (verified by referential equality in the useMemo short-circuits). git revert is clean — nothing depends on these new helpers yet outside of the screen itself.

Note on bookkeeping

This issue was briefly closed on a false premise (I claimed PR #1779 contained this work; it didn't — see correction comment on #1780). Reopened, repeated, and shipping properly here.

Phase 1 of Epic #820. With Phase 0 the schema, content directory, and
loader for chapter_lens_content.panel_filter_json / panel_order_json
shipped, but ChapterScreen consumed only lensContent.guidance — the
filter/order JSON columns were dead. This PR makes them actually do
something.
Utility (new):
- app/src/utils/lensPanelFilter.ts — pure functions:
* parseLensJson(json) — JSON.parse with logger.warn on failure,
returns undefined for missing/malformed/non-array/non-string
* filterPanelKeys(keys, filter) — empty filter == no filter (avoids
rendering zero panels from an authoring bug)
* orderPanelKeys(keys, order) — keys in order first, then unordered
keys appended in original relative order; missing-from-order keys
silently dropped
* applyLensToKeys(keys, filter, order) — convenience that filters
then orders in one pass
Tests (new):
- app/__tests__/unit/lensPanelFilter.test.ts — 28 cases covering
empty filter (deliberate behaviour), missing keys in order, both
fields together, malformed JSON, non-string entries, mutation
safety, and the parseLensJson + applyLensToKeys end-to-end flow.
Wiring in ChapterScreen:
- Lens-data hooks moved earlier in the render flow so derived
filtered sections / chapterPanels can flow into useChapterPanels.
- New lensFilteredSections useMemo — when no lens active, returns
the original sections array by reference (memoised consumers don't
invalidate). When active, maps each section to a copy with its
panels[] filtered + reordered by panel_type.
- New lensFilteredChapterPanels useMemo — same treatment for the
flat chapter-panels list.
- useChapterPanels now receives lensFilteredSections; ChapterVerseList
receives both filtered versions; studyEstimate uses the filtered
panels so depth estimate reflects the lens view.
Behaviour decisions (pinned in tests):
- Empty filter array → treat as 'no filter' (don't render zero panels)
- Order keys not in panels → silently dropped, no crash
- Panels not in order → appended after ordered keys, preserving
original relative order
- Bad JSON → logger.warn, fallback to no transformation
Out of scope:
- Reordering panels within a section's content (we operate at the
panel-key level only)
- New lens content (Phase 2 #1781 Genesis pilot delivers that)
Closes#1780.
@github-actions

github-actionsBot commented Apr 28, 2026

Copy link
Copy Markdown

Test Results

✅ All tests passed

PassedFailedTotal
Tests✅ 4001❌ 04001
Suites✅ 532❌ 0532

Coverage

StatementsBranchesFunctionsLines

⏱️ Duration: 97.7s

Two test cases in lensPanelFilter.test.ts asserted that warnSpy
(jest.spyOn(console, 'warn')) was called when parseLensJson hit
malformed input or non-string entries. They passed locally but
failed in CI with 'Received number of calls: 0'.
Root cause: the project's jest setup silences/replaces console.warn
globally, so a fresh spy registers zero calls regardless of whether
logger.warn fires. Other test files in the repo handle this by
mocking the logger module directly (see useAmicusChipContext.test.tsx,
ProgressRow.test.tsx, etc.).
Fix: follow the project convention.
- jest.mock('@/utils/logger', ...) at module top
- pull mockLogger via require() inside the suite
- replace beforeEach/afterEach spy/restore with jest.clearAllMocks()
- assert against mockLogger.warn instead of warnSpy
Test count and behaviour pins are unchanged (28 cases). Just the
mocking strategy is corrected.
@CraigBuckmaster

Copy link
Copy Markdown
OwnerAuthor

CI fix — test job failure resolved

Two lensPanelFilter.test.ts cases asserted warnSpy (jest.spyOn(console, 'warn')) was called. They passed locally but failed in CI with Received number of calls: 0.

Root cause: the project's jest setup silences/replaces console.warn globally, so a fresh spy registers zero calls regardless of whether logger.warn fires.

Fix: follow the established project pattern — mock the logger module directly. Other test files in the repo do this exact thing (e.g. useAmicusChipContext.test.tsx, ProgressRow.test.tsx, all the db/*.test.ts files).

-let warnSpy: jest.SpyInstance;-beforeEach(() => {- warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined);-});+jest.mock('@/utils/logger', () => ({+ logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },+}));+const { logger: mockLogger } = require('@/utils/logger') as { ... };+beforeEach(() => { jest.clearAllMocks(); });

Test count + behaviour pins unchanged (28 cases). a0530b8 pushed; CI re-running.

@CraigBuckmaster
CraigBuckmaster merged commit 8d770ec into masterApr 28, 2026
6 checks passed
@CraigBuckmaster
CraigBuckmaster deleted the feat/820-1-lens-panel-wiring branch April 28, 2026 18:48
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[#820 / 1] Wire panel_filter_json / panel_order_json in ChapterScreen

1 participant

@CraigBuckmaster