From 6e242adcffd8be8b869a91406a2a999a2556979b Mon Sep 17 00:00:00 2001 From: smithrashell Date: Thu, 5 Feb 2026 14:42:39 -0500 Subject: [PATCH 1/9] fix(ui): replace native select with custom dropdown for Chrome extension compatibility A Chrome browser update broke native ` elements work in extension content scripts + - Created `SimpleSelect.jsx` custom dropdown component that renders within React DOM instead of using browser-native UI + - Custom dropdown includes full keyboard navigation (Arrow keys, Enter, Space, Escape), click-outside-to-close, and maintains visual consistency + - Changed CSS containment from `contain: layout size style` to `contain: paint style` on `.cm-sidenav` in main.css + - Changed CSS containment from `contain: layout` to `contain: paint style` in probrec.css (2 instances) + - CSS containment with `layout` was preventing dropdowns from rendering outside their container bounds + - Maintains compatibility with react-hook-form and all existing functionality + - **Problem List Not Updating After Submission** - Fixed problems not being removed from generator list after submitting a solution - Root cause: Sidebar unmounting fix (Dec 23) prevented natural data refresh on remount diff --git a/chrome-extension-app/src/content/css/main.css b/chrome-extension-app/src/content/css/main.css index e302e572..afb492d8 100644 --- a/chrome-extension-app/src/content/css/main.css +++ b/chrome-extension-app/src/content/css/main.css @@ -357,10 +357,9 @@ body[data-theme="light"] .cm-extension #cm-menuButton:hover { ); height: 100vh; max-height: 100vh; - /* Prevent affecting page layout */ - contain: layout size style; - /* Ensure sidebar doesn't compete with dropdown z-index */ - z-index: 9000 !important; + /* Prevent affecting page layout - use paint/style containment only + Note: 'layout' containment can break native dropdowns */ + contain: paint style !important; } /* Override height constraints when strategy map content is expanded */ @@ -1736,8 +1737,8 @@ html body .cm-extension .cm-sidenav.problem-sidebar-view, html body .cm-extension .cd-sidenav.problem-sidebar-view, html body .problem-sidebar { overflow-y: visible !important; - /* Ensure parent allows child to handle overflow */ - contain: layout !important; + /* Use paint/style containment only - layout containment breaks native to work around Chrome + * extension content script issues with native select dropdowns. + * * Features: - * - Uses native HTML { + const [isOpen, setIsOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + const containerRef = useRef(null); + const dropdownRef = useRef(null); + + // Parse children to extract options + const options = React.Children.toArray(children) + .filter(child => child.type === 'option') + .map(child => ({ + value: child.props.value, + label: child.props.children, + disabled: child.props.disabled + })); + + // Find the currently selected option's label + const selectedOption = options.find(opt => String(opt.value) === String(value)); + const displayText = selectedOption?.label || options[0]?.label || 'Select...'; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event) => { + if (containerRef.current && !containerRef.current.contains(event.target)) { + setIsOpen(false); + } + }; + + if (isOpen) { + // Use capture phase to catch events before they're stopped + document.addEventListener('mousedown', handleClickOutside, true); + return () => document.removeEventListener('mousedown', handleClickOutside, true); + } + }, [isOpen]); + + // Handle keyboard navigation + const handleKeyDown = (e) => { + if (!isOpen) { + if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') { + e.preventDefault(); + setIsOpen(true); + setFocusedIndex(options.findIndex(opt => String(opt.value) === String(value))); + } + return; + } + + switch (e.key) { + case 'Escape': + e.preventDefault(); + setIsOpen(false); + break; + case 'ArrowDown': + e.preventDefault(); + setFocusedIndex(prev => { + const next = prev + 1; + return next >= options.length ? 0 : next; + }); + break; + case 'ArrowUp': + e.preventDefault(); + setFocusedIndex(prev => { + const next = prev - 1; + return next < 0 ? options.length - 1 : next; + }); + break; + case 'Enter': + case ' ': + e.preventDefault(); + if (focusedIndex >= 0 && !options[focusedIndex]?.disabled) { + handleSelect(options[focusedIndex].value); + } + break; + default: + break; + } + }; + + const handleSelect = (optionValue) => { + // Create a synthetic event similar to native select onChange + const syntheticEvent = { + target: { value: optionValue }, + currentTarget: { value: optionValue }, + preventDefault: () => {}, + stopPropagation: () => {} + }; + onChange(syntheticEvent); + setIsOpen(false); + }; + + const toggleDropdown = (e) => { + e.preventDefault(); + e.stopPropagation(); + setIsOpen(!isOpen); + if (!isOpen) { + setFocusedIndex(options.findIndex(opt => String(opt.value) === String(value))); + } + }; + + const baseStyles = { + container: { + position: 'relative', + width: '100%', + maxWidth: '100%', + }, + trigger: { width: '100%', maxWidth: '100%', padding: '6px 8px', @@ -39,29 +139,124 @@ const SimpleSelect = React.forwardRef(({ value, onChange, children, error, ...pr cursor: 'pointer', transition: 'all 0.2s ease', boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)', - appearance: 'none', // Remove default styling backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`, backgroundPosition: 'right 8px center', backgroundRepeat: 'no-repeat', backgroundSize: '16px', - paddingRight: '32px' - }} - onFocus={(e) => { - e.target.style.borderColor = error ? '#ef4444' : 'var(--cm-active-blue)'; - e.target.style.boxShadow = error - ? '0 0 0 3px rgba(239, 68, 68, 0.1)' - : '0 0 0 3px rgba(37, 99, 235, 0.1)'; - }} - onBlur={(e) => { - e.target.style.borderColor = error ? '#ef4444' : 'var(--cm-border)'; - e.target.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.05)'; - }} - {...props} - > - {children} - -)); + paddingRight: '32px', + textAlign: 'left', + display: 'block', + }, + dropdown: { + position: 'absolute', + top: '100%', + left: 0, + right: 0, + marginTop: '4px', + backgroundColor: 'var(--cm-card-bg)', + border: '1px solid var(--cm-border)', + borderRadius: '6px', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + zIndex: 10000, + maxHeight: '200px', + overflowY: 'auto', + overflowX: 'hidden', + }, + option: { + padding: '8px 12px', + cursor: 'pointer', + fontSize: '13px', + color: 'var(--cm-text)', + backgroundColor: 'transparent', + transition: 'background-color 0.15s ease', + textAlign: 'left', + }, + optionHovered: { + backgroundColor: 'var(--cm-active-blue)', + color: 'white', + }, + optionSelected: { + backgroundColor: 'rgba(37, 99, 235, 0.1)', + }, + optionDisabled: { + color: 'var(--cm-link-color)', + cursor: 'not-allowed', + opacity: 0.6, + } + }; + + return ( +
+ + + {isOpen && ( +
+ {options.map((option, index) => { + const isSelected = String(option.value) === String(value); + const isFocused = index === focusedIndex; + const isDisabled = option.disabled; + + return ( +
{ + e.preventDefault(); + e.stopPropagation(); + if (!isDisabled) { + handleSelect(option.value); + } + }} + onMouseEnter={() => !isDisabled && setFocusedIndex(index)} + style={{ + ...baseStyles.option, + ...(isSelected && !isFocused ? baseStyles.optionSelected : {}), + ...(isFocused && !isDisabled ? baseStyles.optionHovered : {}), + ...(isDisabled ? baseStyles.optionDisabled : {}), + }} + className="cm-simple-select-option" + > + {option.label} +
+ ); + })} +
+ )} +
+ ); +}); SimpleSelect.displayName = 'SimpleSelect'; -export default SimpleSelect; \ No newline at end of file +export default SimpleSelect; From 51cd508af05271257d1541e70c212b829055c6c5 Mon Sep 17 00:00:00 2001 From: smithrashell Date: Thu, 5 Feb 2026 14:44:15 -0500 Subject: [PATCH 2/9] test(session): add Guard Rail 4 and escape hatch promotion type tests Co-Authored-By: Claude Opus 4.5 --- .../sessionEscapeHatchHelpers.test.js | 316 ++++++++++++++++++ .../utils/__tests__/sessionBalancing.test.js | 244 ++++++++++++++ 2 files changed, 560 insertions(+) create mode 100644 chrome-extension-app/src/shared/db/stores/__tests__/sessionEscapeHatchHelpers.test.js create mode 100644 chrome-extension-app/src/shared/utils/__tests__/sessionBalancing.test.js diff --git a/chrome-extension-app/src/shared/db/stores/__tests__/sessionEscapeHatchHelpers.test.js b/chrome-extension-app/src/shared/db/stores/__tests__/sessionEscapeHatchHelpers.test.js new file mode 100644 index 00000000..1ace2b69 --- /dev/null +++ b/chrome-extension-app/src/shared/db/stores/__tests__/sessionEscapeHatchHelpers.test.js @@ -0,0 +1,316 @@ +/** + * Tests for Session Escape Hatch Helpers + * + * Tests promotion type tracking (current_promotion_type) used by + * Guard Rail 4 (poor performance protection) in session composition. + */ + +import { applyEscapeHatchLogic } from '../sessionEscapeHatchHelpers.js'; + +// Mock logger to suppress console output during tests +jest.mock('../../../utils/logging/logger.js', () => ({ + __esModule: true, + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +// Mock sessionAnalytics (not used in these specific tests but imported by the module) +jest.mock('../sessionAnalytics.js', () => ({ + getRecentSessionAnalytics: jest.fn(() => Promise.resolve([])), +})); + +describe('Session Escape Hatch Helpers', () => { + // Suppress console.log from the source file + beforeEach(() => { + jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('current_promotion_type tracking', () => { + it('initializes current_promotion_type as null for new session state', () => { + const sessionState = { + current_difficulty_cap: 'Easy', + difficulty_time_stats: { + easy: { problems: 0, total_time: 0, avg_time: 0 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + } + }; + + applyEscapeHatchLogic(sessionState, 0.8, {}, new Date()); + + expect(sessionState.escape_hatches).toBeDefined(); + expect(sessionState.escape_hatches.current_promotion_type).toBeNull(); + }); + + it('sets current_promotion_type to standard_volume_gate on standard promotion', () => { + const sessionState = { + current_difficulty_cap: 'Easy', + difficulty_time_stats: { + easy: { problems: 4, total_time: 2400, avg_time: 600 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + } + }; + + // 4 problems + 80% accuracy = standard promotion criteria met + applyEscapeHatchLogic(sessionState, 0.8, {}, new Date()); + + expect(sessionState.current_difficulty_cap).toBe('Medium'); + expect(sessionState.escape_hatches.current_promotion_type).toBe('standard_volume_gate'); + }); + + it('sets current_promotion_type to stagnation_escape_hatch on stagnation promotion', () => { + const sessionState = { + current_difficulty_cap: 'Easy', + difficulty_time_stats: { + easy: { problems: 8, total_time: 4800, avg_time: 600 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + } + }; + + // 8 problems = stagnation escape (regardless of accuracy) + applyEscapeHatchLogic(sessionState, 0.3, {}, new Date()); + + expect(sessionState.current_difficulty_cap).toBe('Medium'); + expect(sessionState.escape_hatches.current_promotion_type).toBe('stagnation_escape_hatch'); + }); + + it('preserves current_promotion_type across calls without promotion', () => { + const sessionState = { + current_difficulty_cap: 'Hard', + difficulty_time_stats: { + easy: { problems: 0, total_time: 0, avg_time: 0 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 2, total_time: 3600, avg_time: 1800 } + }, + escape_hatches: { + sessions_at_current_difficulty: 3, + current_promotion_type: 'stagnation_escape_hatch', + activated_escape_hatches: [], + last_difficulty_promotion: '2024-01-01T00:00:00.000Z', + sessions_without_promotion: 0 + } + }; + + // No promotion should occur (already at Hard, not enough problems) + applyEscapeHatchLogic(sessionState, 0.5, {}, new Date()); + + // Promotion type should be preserved since no new promotion occurred + expect(sessionState.escape_hatches.current_promotion_type).toBe('stagnation_escape_hatch'); + }); + + it('clears activated_escape_hatches on promotion but keeps promotion type', () => { + const sessionState = { + current_difficulty_cap: 'Easy', + difficulty_time_stats: { + easy: { problems: 8, total_time: 4800, avg_time: 600 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + }, + escape_hatches: { + sessions_at_current_difficulty: 5, + current_promotion_type: null, + activated_escape_hatches: ['some-previous-hatch'], + last_difficulty_promotion: null, + sessions_without_promotion: 3 + } + }; + + applyEscapeHatchLogic(sessionState, 0.3, {}, new Date()); + + // After promotion, activated_escape_hatches should be cleared + expect(sessionState.escape_hatches.activated_escape_hatches).toEqual([]); + // But promotion type should be set + expect(sessionState.escape_hatches.current_promotion_type).toBe('stagnation_escape_hatch'); + }); + + it('tracks stagnation activation in activated_escape_hatches before promotion', () => { + const sessionState = { + current_difficulty_cap: 'Easy', + difficulty_time_stats: { + easy: { problems: 8, total_time: 4800, avg_time: 600 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + } + }; + + applyEscapeHatchLogic(sessionState, 0.3, {}, new Date()); + + // After promotion via stagnation, activated_escape_hatches is cleared + // but we can verify the promotion type indicates stagnation + expect(sessionState.escape_hatches.current_promotion_type).toBe('stagnation_escape_hatch'); + }); + }); + + describe('standard promotion flow', () => { + it('promotes from Easy to Medium with 4+ problems and 80%+ accuracy', () => { + const sessionState = { + current_difficulty_cap: 'Easy', + difficulty_time_stats: { + easy: { problems: 5, total_time: 3000, avg_time: 600 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + } + }; + + applyEscapeHatchLogic(sessionState, 0.85, {}, new Date()); + + expect(sessionState.current_difficulty_cap).toBe('Medium'); + expect(sessionState.escape_hatches.current_promotion_type).toBe('standard_volume_gate'); + }); + + it('promotes from Medium to Hard with 4+ problems and 80%+ accuracy', () => { + const sessionState = { + current_difficulty_cap: 'Medium', + difficulty_time_stats: { + easy: { problems: 0, total_time: 0, avg_time: 0 }, + medium: { problems: 4, total_time: 4800, avg_time: 1200 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + } + }; + + applyEscapeHatchLogic(sessionState, 0.8, {}, new Date()); + + expect(sessionState.current_difficulty_cap).toBe('Hard'); + expect(sessionState.escape_hatches.current_promotion_type).toBe('standard_volume_gate'); + }); + + it('does NOT promote when accuracy below 80% and problems below 8', () => { + const sessionState = { + current_difficulty_cap: 'Easy', + difficulty_time_stats: { + easy: { problems: 4, total_time: 2400, avg_time: 600 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + } + }; + + applyEscapeHatchLogic(sessionState, 0.7, {}, new Date()); + + expect(sessionState.current_difficulty_cap).toBe('Easy'); + expect(sessionState.escape_hatches.current_promotion_type).toBeNull(); + }); + }); + + describe('stagnation escape flow', () => { + it('promotes via stagnation with 8+ problems regardless of accuracy', () => { + const sessionState = { + current_difficulty_cap: 'Medium', + difficulty_time_stats: { + easy: { problems: 0, total_time: 0, avg_time: 0 }, + medium: { problems: 10, total_time: 12000, avg_time: 1200 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + } + }; + + // Even with very low accuracy + applyEscapeHatchLogic(sessionState, 0.2, {}, new Date()); + + expect(sessionState.current_difficulty_cap).toBe('Hard'); + expect(sessionState.escape_hatches.current_promotion_type).toBe('stagnation_escape_hatch'); + }); + + it('prefers standard promotion over stagnation when both criteria met', () => { + const sessionState = { + current_difficulty_cap: 'Easy', + difficulty_time_stats: { + easy: { problems: 10, total_time: 6000, avg_time: 600 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + } + }; + + // Both standard (10 >= 4, 90% >= 80%) and stagnation (10 >= 8) criteria met + applyEscapeHatchLogic(sessionState, 0.9, {}, new Date()); + + expect(sessionState.current_difficulty_cap).toBe('Medium'); + // Standard promotion takes precedence + expect(sessionState.escape_hatches.current_promotion_type).toBe('standard_volume_gate'); + }); + }); + + describe('Hard cap behavior', () => { + it('does NOT promote beyond Hard', () => { + const sessionState = { + current_difficulty_cap: 'Hard', + difficulty_time_stats: { + easy: { problems: 0, total_time: 0, avg_time: 0 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 10, total_time: 18000, avg_time: 1800 } + }, + escape_hatches: { + sessions_at_current_difficulty: 5, + current_promotion_type: 'standard_volume_gate', + activated_escape_hatches: [], + last_difficulty_promotion: '2024-01-01T00:00:00.000Z', + sessions_without_promotion: 0 + } + }; + + const initialPromotionType = sessionState.escape_hatches.current_promotion_type; + applyEscapeHatchLogic(sessionState, 0.95, {}, new Date()); + + // Should still be at Hard + expect(sessionState.current_difficulty_cap).toBe('Hard'); + // Promotion type should be preserved (no new promotion occurred) + expect(sessionState.escape_hatches.current_promotion_type).toBe(initialPromotionType); + }); + }); + + describe('sessions_at_current_difficulty tracking', () => { + it('resets sessions_at_current_difficulty on promotion', () => { + const sessionState = { + current_difficulty_cap: 'Easy', + difficulty_time_stats: { + easy: { problems: 4, total_time: 2400, avg_time: 600 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + }, + escape_hatches: { + sessions_at_current_difficulty: 5, + current_promotion_type: null, + activated_escape_hatches: [], + last_difficulty_promotion: null, + sessions_without_promotion: 3 + } + }; + + applyEscapeHatchLogic(sessionState, 0.8, {}, new Date()); + + // After promotion, sessions counter should be reset to 0 + expect(sessionState.escape_hatches.sessions_at_current_difficulty).toBe(0); + }); + + it('increments sessions_at_current_difficulty when no promotion', () => { + const sessionState = { + current_difficulty_cap: 'Easy', + difficulty_time_stats: { + easy: { problems: 2, total_time: 1200, avg_time: 600 }, + medium: { problems: 0, total_time: 0, avg_time: 0 }, + hard: { problems: 0, total_time: 0, avg_time: 0 } + }, + escape_hatches: { + sessions_at_current_difficulty: 3, + current_promotion_type: null, + activated_escape_hatches: [], + last_difficulty_promotion: null, + sessions_without_promotion: 2 + } + }; + + applyEscapeHatchLogic(sessionState, 0.5, {}, new Date()); + + // Should be incremented (3 + 1 = 4) + expect(sessionState.escape_hatches.sessions_at_current_difficulty).toBe(4); + }); + }); +}); diff --git a/chrome-extension-app/src/shared/utils/__tests__/sessionBalancing.test.js b/chrome-extension-app/src/shared/utils/__tests__/sessionBalancing.test.js new file mode 100644 index 00000000..f4ac1a27 --- /dev/null +++ b/chrome-extension-app/src/shared/utils/__tests__/sessionBalancing.test.js @@ -0,0 +1,244 @@ +/** + * Tests for Session Balancing Guard Rails + * + * Tests Guard Rail 4 (poor performance protection) and backward compatibility + * with existing guard rails 1-3. + */ + +import { applySafetyGuardRails } from '../session/sessionBalancing.js'; + +// Mock logger to suppress console output during tests +jest.mock('../logging/logger.js', () => ({ + __esModule: true, + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, +})); + +describe('Session Balancing - Guard Rails', () => { + // Helper to create mock problems with specified difficulty distribution + const createProblems = (easy, medium, hard) => [ + ...Array(easy).fill(null).map((_, i) => ({ + id: i + 1, + difficulty: 'Easy', + title: `Easy Problem ${i + 1}` + })), + ...Array(medium).fill(null).map((_, i) => ({ + id: easy + i + 1, + difficulty: 'Medium', + title: `Medium Problem ${i + 1}` + })), + ...Array(hard).fill(null).map((_, i) => ({ + id: easy + medium + i + 1, + difficulty: 'Hard', + title: `Hard Problem ${i + 1}` + })) + ]; + + describe('Guard Rail 4: Poor Performance Protection', () => { + const stagnationPromotion = 'stagnation_escape_hatch'; + const standardPromotion = 'standard_volume_gate'; + const poorPerformance = { accuracy: 0.40, sessionsAnalyzed: 3 }; + const goodPerformance = { accuracy: 0.60, sessionsAnalyzed: 3 }; + + it('triggers when all conditions met: Hard cap, stagnation, poor accuracy, multiple Hard', () => { + const problems = createProblems(2, 2, 3); // 3 Hard problems + const result = applySafetyGuardRails(problems, 'Hard', 5, poorPerformance, stagnationPromotion); + + expect(result.needsRebalance).toBe(true); + expect(result.guardRailType).toBe('poor_performance_protection'); + expect(result.excessHard).toBe(2); // 3 - 1 = 2 excess + expect(result.replacementDifficulty).toBe('Medium'); + }); + + it('does NOT trigger when accuracy >= 50%', () => { + const problems = createProblems(2, 2, 3); + const result = applySafetyGuardRails(problems, 'Hard', 5, goodPerformance, stagnationPromotion); + + expect(result.needsRebalance).toBe(false); + }); + + it('does NOT trigger for standard_volume_gate promotion', () => { + const problems = createProblems(2, 2, 3); + const result = applySafetyGuardRails(problems, 'Hard', 5, poorPerformance, standardPromotion); + + expect(result.needsRebalance).toBe(false); + }); + + it('does NOT trigger Guard Rail 4 when Hard count is 1 or less', () => { + // Note: With Hard count = 1, Guard Rail 2 may trigger (minimum 2 Hard for Hard cap) + // This test verifies Guard Rail 4 specifically does not trigger + const problems = createProblems(4, 2, 1); // Only 1 Hard + const result = applySafetyGuardRails(problems, 'Hard', 5, poorPerformance, stagnationPromotion); + + // Guard Rail 4 should not trigger (it requires Hard > 1) + // Guard Rail 2 may trigger instead (requiring minimum 2 Hard) + expect(result.guardRailType).not.toBe('poor_performance_protection'); + }); + + it('does NOT trigger when recentPerformance is null', () => { + const problems = createProblems(2, 2, 3); + const result = applySafetyGuardRails(problems, 'Hard', 5, null, stagnationPromotion); + + expect(result.needsRebalance).toBe(false); + }); + + it('does NOT trigger when difficulty cap is Medium', () => { + const problems = createProblems(2, 2, 3); + const result = applySafetyGuardRails(problems, 'Medium', 5, poorPerformance, stagnationPromotion); + + // May trigger other guard rails but not Guard Rail 4 + expect(result.guardRailType).not.toBe('poor_performance_protection'); + }); + + it('does NOT trigger when difficulty cap is Easy', () => { + const problems = createProblems(2, 2, 3); + const result = applySafetyGuardRails(problems, 'Easy', 5, poorPerformance, stagnationPromotion); + + // Should not trigger Guard Rail 4 at Easy cap + expect(result.guardRailType).not.toBe('poor_performance_protection'); + }); + + it('handles boundary: exactly 50% accuracy does NOT trigger', () => { + const problems = createProblems(2, 2, 3); + const boundaryPerformance = { accuracy: 0.50, sessionsAnalyzed: 3 }; + const result = applySafetyGuardRails(problems, 'Hard', 5, boundaryPerformance, stagnationPromotion); + + // 50% is the threshold, so exactly 50% should NOT trigger + expect(result.guardRailType).not.toBe('poor_performance_protection'); + }); + + it('handles boundary: 49% accuracy triggers', () => { + const problems = createProblems(2, 2, 3); + const boundaryPerformance = { accuracy: 0.49, sessionsAnalyzed: 3 }; + const result = applySafetyGuardRails(problems, 'Hard', 5, boundaryPerformance, stagnationPromotion); + + expect(result.needsRebalance).toBe(true); + expect(result.guardRailType).toBe('poor_performance_protection'); + }); + + it('returns correct excessHard count for various Hard counts', () => { + // Test with 4 Hard problems - should have 3 excess (4 - 1) + const problems4Hard = createProblems(2, 1, 4); + const result4 = applySafetyGuardRails(problems4Hard, 'Hard', 5, poorPerformance, stagnationPromotion); + expect(result4.excessHard).toBe(3); + + // Test with 2 Hard problems - should have 1 excess (2 - 1) + const problems2Hard = createProblems(3, 2, 2); + const result2 = applySafetyGuardRails(problems2Hard, 'Hard', 5, poorPerformance, stagnationPromotion); + expect(result2.excessHard).toBe(1); + }); + + it('does not trigger with null promotion type', () => { + const problems = createProblems(2, 2, 3); + const result = applySafetyGuardRails(problems, 'Hard', 5, poorPerformance, null); + + expect(result.needsRebalance).toBe(false); + }); + + it('does not trigger with undefined promotion type', () => { + const problems = createProblems(2, 2, 3); + const result = applySafetyGuardRails(problems, 'Hard', 5, poorPerformance, undefined); + + expect(result.needsRebalance).toBe(false); + }); + }); + + // Existing guard rails should still work + describe('Guard Rails 1-3 backward compatibility', () => { + describe('Guard Rail 1: Medium cap minimum Medium problems', () => { + it('triggers when Medium cap but only 1 Medium problem (session >= 4)', () => { + const problems = createProblems(5, 1, 0); // Only 1 Medium at Medium cap + const result = applySafetyGuardRails(problems, 'Medium', 5, null, null); + + expect(result.needsRebalance).toBe(true); + expect(result.target.Medium).toBe(2); + }); + + it('does NOT trigger when enough Medium problems exist', () => { + const problems = createProblems(3, 3, 0); // 3 Medium problems + const result = applySafetyGuardRails(problems, 'Medium', 5, null, null); + + expect(result.needsRebalance).toBe(false); + }); + + it('does NOT trigger when Medium count is 0 (handles case of no Medium problems gracefully)', () => { + const problems = createProblems(6, 0, 0); // No Medium problems + const result = applySafetyGuardRails(problems, 'Medium', 5, null, null); + + // Guard rail only triggers when Medium > 0 but < minMedium + expect(result.needsRebalance).toBe(false); + }); + + it('does NOT trigger when session length is small (< 4)', () => { + const problems = createProblems(2, 1, 0); // Small session + const result = applySafetyGuardRails(problems, 'Medium', 5, null, null); + + expect(result.needsRebalance).toBe(false); + }); + }); + + describe('Guard Rail 2: Hard cap minimum Hard problems', () => { + it('triggers when Hard cap but only 1 Hard problem (session >= 5)', () => { + const problems = createProblems(3, 2, 1); // Only 1 Hard at Hard cap + const result = applySafetyGuardRails(problems, 'Hard', 5, null, null); + + expect(result.needsRebalance).toBe(true); + expect(result.target.Hard).toBe(2); + }); + + it('does NOT trigger when enough Hard problems exist', () => { + const problems = createProblems(2, 2, 3); // 3 Hard problems + const result = applySafetyGuardRails(problems, 'Hard', 5, null, null); + + expect(result.needsRebalance).toBe(false); + }); + + it('does NOT trigger when Hard count is 0', () => { + const problems = createProblems(3, 3, 0); // No Hard problems + const result = applySafetyGuardRails(problems, 'Hard', 5, null, null); + + // Guard rail only triggers when Hard > 0 but < minHard + expect(result.needsRebalance).toBe(false); + }); + }); + + describe('Guard Rail 3: First sessions at new difficulty', () => { + it('triggers when first session at Medium cap with insufficient Medium problems', () => { + const problems = createProblems(4, 1, 0); // Only 1 Medium + const result = applySafetyGuardRails(problems, 'Medium', 0, null, null); // sessionsAtCurrentDifficulty = 0 + + expect(result.needsRebalance).toBe(true); + expect(result.target.Medium).toBe(2); + }); + + it('triggers when first session at Hard cap with insufficient Hard problems', () => { + const problems = createProblems(3, 2, 1); // Only 1 Hard + const result = applySafetyGuardRails(problems, 'Hard', 0, null, null); // sessionsAtCurrentDifficulty = 0 + + expect(result.needsRebalance).toBe(true); + expect(result.target.Hard).toBe(2); + }); + + it('does NOT trigger at Easy cap (even for first session)', () => { + const problems = createProblems(5, 0, 0); + const result = applySafetyGuardRails(problems, 'Easy', 0, null, null); + + expect(result.needsRebalance).toBe(false); + }); + + it('does NOT trigger after 2+ sessions at current difficulty', () => { + const problems = createProblems(4, 1, 0); + const result = applySafetyGuardRails(problems, 'Medium', 2, null, null); // sessionsAtCurrentDifficulty = 2 + + // Guard rail 3 doesn't apply, but guard rail 1 might + // With 1 Medium and Medium cap at session length 5, guard rail 1 applies + // This test just verifies guard rail 3 logic (sessionsAtCurrentDifficulty >= 2) + expect(result.message).not.toContain('First session'); + }); + }); + }); +}); From 9fcfa30361597fdadb31e890b2e5b6df9719e4da Mon Sep 17 00:00:00 2001 From: smithrashell Date: Thu, 5 Feb 2026 14:48:40 -0500 Subject: [PATCH 3/9] feat(session): add Guard Rail 4 for poor performance protection Co-Authored-By: Claude Opus 4.5 --- .../db/stores/sessionEscapeHatchHelpers.js | 4 ++- .../shared/utils/session/sessionBalancing.js | 34 +++++++++++++++++-- 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/chrome-extension-app/src/shared/db/stores/sessionEscapeHatchHelpers.js b/chrome-extension-app/src/shared/db/stores/sessionEscapeHatchHelpers.js index 68b64d93..18f6cd36 100644 --- a/chrome-extension-app/src/shared/db/stores/sessionEscapeHatchHelpers.js +++ b/chrome-extension-app/src/shared/db/stores/sessionEscapeHatchHelpers.js @@ -72,6 +72,7 @@ function initializeEscapeHatches(sessionState) { last_difficulty_promotion: null, sessions_without_promotion: 0, activated_escape_hatches: [], + current_promotion_type: null, // 'standard_volume_gate' | 'stagnation_escape_hatch' | null }; } @@ -127,7 +128,8 @@ function promoteDifficulty(context, newDifficulty) { sessionState.current_difficulty_cap = newDifficulty; escapeHatches.last_difficulty_promotion = now.toISOString(); escapeHatches.sessions_at_current_difficulty = 0; - escapeHatches.activated_escape_hatches = []; + escapeHatches.current_promotion_type = promotionReason; // Store promotion type for session composition safety + escapeHatches.activated_escape_hatches = []; // Clear activation history if (promotionReason === "stagnation_escape_hatch") { logger.info(`Difficulty cap upgraded via STAGNATION ESCAPE: ${oldDifficulty} → ${newDifficulty} (${problemsAtDifficulty} problems)`); diff --git a/chrome-extension-app/src/shared/utils/session/sessionBalancing.js b/chrome-extension-app/src/shared/utils/session/sessionBalancing.js index 1e72d2a4..a14b59c7 100644 --- a/chrome-extension-app/src/shared/utils/session/sessionBalancing.js +++ b/chrome-extension-app/src/shared/utils/session/sessionBalancing.js @@ -70,13 +70,22 @@ export function calculateAccuracyAtDifficulty(difficulty, difficultyTimeStats, r * - At Medium cap: At least 2 Medium problems (or 25% if session > 8) * - At Hard cap: At least 2 Hard problems (or 20% if session > 10) * - First 2 sessions at new difficulty: At least 2 problems at cap difficulty + * - Stagnation escape with poor performance: Limit Hard to 1 problem * * @param {Array} problems - Selected problems for the session * @param {string} currentDifficultyCap - Current difficulty cap ('Easy', 'Medium', 'Hard') * @param {number} sessionsAtCurrentDifficulty - Number of sessions at current difficulty - * @returns {Object} { needsRebalance: boolean, target: {difficulty: count}, message: string } + * @param {Object|null} recentPerformance - Recent performance data { accuracy, sessionsAnalyzed } + * @param {string|null} currentPromotionType - How user reached current difficulty ('standard_volume_gate' | 'stagnation_escape_hatch' | null) + * @returns {Object} { needsRebalance: boolean, target: {difficulty: count}, message: string, guardRailType?: string } */ -export function applySafetyGuardRails(problems, currentDifficultyCap, sessionsAtCurrentDifficulty) { +export function applySafetyGuardRails( + problems, + currentDifficultyCap, + sessionsAtCurrentDifficulty, + recentPerformance = null, + currentPromotionType = null +) { const sessionLength = problems.length; // Count current difficulty distribution @@ -134,6 +143,27 @@ export function applySafetyGuardRails(problems, currentDifficultyCap, sessionsAt } } + // Guard Rail 4: Poor Performance Protection + // When at Hard cap via stagnation escape with poor accuracy, limit Hard to 1 + if (currentDifficultyCap === 'Hard' && recentPerformance !== null) { + const isStagnationPromotion = currentPromotionType === 'stagnation_escape_hatch'; + const isPoorPerformance = recentPerformance.accuracy < 0.5; // 50% threshold + const maxHard = 1; // ALWAYS max 1 Hard for stagnation + poor performance + + if (isStagnationPromotion && isPoorPerformance && counts.Hard > maxHard) { + const message = `Stagnation escape with ${(recentPerformance.accuracy * 100).toFixed(0)}% accuracy - limiting Hard to ${maxHard} (was ${counts.Hard})`; + logger.warn(`⚖️ Guard rail 4 triggered: ${message}`); + return { + needsRebalance: true, + target: { Hard: maxHard }, + excessHard: counts.Hard - maxHard, + replacementDifficulty: 'Medium', + message, + guardRailType: 'poor_performance_protection' + }; + } + } + logger.info(`✅ Guard rails passed: Session difficulty distribution is acceptable`); return { needsRebalance: false }; } From a248862071b2ad0b205517ed449bf62e641e582a Mon Sep 17 00:00:00 2001 From: smithrashell Date: Thu, 5 Feb 2026 16:56:05 -0500 Subject: [PATCH 4/9] feat(skip): add skip reason selection with prerequisite problem finding Co-Authored-By: Claude Opus 4.5 --- chrome-extension-app/public/manifest.json | 5 +- .../background/handlers/problemHandlers.js | 85 +++++++- chrome-extension-app/src/content/App.jsx | 14 ++ .../features/problems/ProblemDetail.jsx | 12 +- .../content/features/problems/SkipReason.jsx | 168 ++++++++++++++++ .../shared/db/stores/problem_relationships.js | 186 ++++++++++++++++++ .../shared/services/problem/problemService.js | 5 +- .../services/problem/problemServiceSession.js | 148 +++++++++++++- .../shared/services/session/sessionService.js | 26 ++- 9 files changed, 629 insertions(+), 20 deletions(-) create mode 100644 chrome-extension-app/src/content/features/problems/SkipReason.jsx diff --git a/chrome-extension-app/public/manifest.json b/chrome-extension-app/public/manifest.json index 30cec055..ec59563f 100644 --- a/chrome-extension-app/public/manifest.json +++ b/chrome-extension-app/public/manifest.json @@ -1,6 +1,6 @@ { "name": "CodeMaster - Algorithm Learning Assistant", - "version": "1.0.0", + "version": "1.1.0", "description": "Master algorithms with personalized spaced repetition and pattern ladders. Track your LeetCode progress with smart analytics.", "manifest_version": 3, "homepage_url": "https://github.com/smithrashell/CodeMaster", @@ -46,8 +46,7 @@ "run_at": "document_idle", "js": [ "content.js" - ], - "media": [] + ] } ], "web_accessible_resources": [ diff --git a/chrome-extension-app/src/background/handlers/problemHandlers.js b/chrome-extension-app/src/background/handlers/problemHandlers.js index 40221fd1..ec53eef6 100644 --- a/chrome-extension-app/src/background/handlers/problemHandlers.js +++ b/chrome-extension-app/src/background/handlers/problemHandlers.js @@ -15,6 +15,13 @@ import { ProblemService } from "../../shared/services/problem/problemService.js"; import { AttemptsService } from "../../shared/services/attempts/attemptsService.js"; import { getProblemWithOfficialDifficulty } from "../../shared/db/stores/problems.js"; +import { + weakenRelationshipsForSkip, + hasRelationshipsToAttempted, + findPrerequisiteProblem +} from "../../shared/db/stores/problem_relationships.js"; +import { SessionService } from "../../shared/services/session/sessionService.js"; +import { getLatestSession } from "../../shared/db/stores/sessions.js"; /** * Handler: getProblemByDescription @@ -113,13 +120,81 @@ export function handleProblemSubmitted(request, dependencies, sendResponse, fini /** * Handler: skipProblem - * Acknowledges problem skip request + * Handles problem skip with reason-based behavior + * + * Skip reasons and their actions: + * - "too_difficult": Weaken graph relationships (if problem has connections) + * - "dont_understand": Find prerequisite problem as replacement + * - "not_relevant": Just remove from session + * - "other": Just remove from session + * + * Free skip: Problems with zero relationships to attempted problems get no graph penalty */ export function handleSkipProblem(request, dependencies, sendResponse, finishRequest) { - console.log("⏭️ Skipping problem:", request.consentScriptData?.leetcode_id || "unknown"); - // Acknowledge the skip request - no additional processing needed - sendResponse({ message: "Problem skipped successfully" }); - finishRequest(); + const leetcodeId = request.leetcodeId || request.problemData?.leetcode_id || request.consentScriptData?.leetcode_id; + const skipReason = request.skipReason || 'other'; + + console.log(`⏭️ Skipping problem ${leetcodeId} - Reason: ${skipReason}`); + + (async () => { + try { + const result = { + message: "Problem skipped successfully", + skipReason, + prerequisite: null, + graphUpdated: false, + freeSkip: false + }; + + // Check if this is a "free skip" (no relationships to attempted problems) + const hasRelationships = await hasRelationshipsToAttempted(leetcodeId); + result.freeSkip = !hasRelationships; + + // Handle based on skip reason + if (skipReason === 'too_difficult' && hasRelationships) { + // Weaken graph relationships for "too difficult" skips + const weakenResult = await weakenRelationshipsForSkip(leetcodeId); + result.graphUpdated = weakenResult.updated > 0; + console.log(`📉 Graph updated: ${weakenResult.updated} relationships weakened`); + } else if (skipReason === 'dont_understand') { + // Find prerequisite problem for "don't understand" skips + // Get current session to exclude problems already in it + const session = await getLatestSession(); + const excludeIds = session?.problems?.map(p => p.leetcode_id) || []; + + const prerequisite = await findPrerequisiteProblem(leetcodeId, excludeIds); + if (prerequisite) { + result.prerequisite = prerequisite; + result.replaced = true; + console.log(`🎓 Found prerequisite: ${prerequisite.title}`); + // Remove skipped problem and add prerequisite as replacement + await SessionService.skipProblem(leetcodeId, prerequisite); + console.log(`✅ Replaced problem ${leetcodeId} with prerequisite ${prerequisite.leetcode_id || prerequisite.id}`); + } else { + // No prerequisite found, just remove + await SessionService.skipProblem(leetcodeId); + console.log(`✅ Removed problem ${leetcodeId} from session (no prerequisite found)`); + } + } else { + // "not_relevant", "other", or "too_difficult" - just remove from session + if (leetcodeId) { + await SessionService.skipProblem(leetcodeId); + console.log(`✅ Removed problem ${leetcodeId} from session`); + } + } + + sendResponse(result); + } catch (error) { + console.error("❌ Error in skipProblem handler:", error); + sendResponse({ + message: "Problem skipped with errors", + error: error.message + }); + } finally { + finishRequest(); + } + })(); + return true; } diff --git a/chrome-extension-app/src/content/App.jsx b/chrome-extension-app/src/content/App.jsx index 143e5bed..a536bb2f 100644 --- a/chrome-extension-app/src/content/App.jsx +++ b/chrome-extension-app/src/content/App.jsx @@ -6,6 +6,7 @@ import ProbStat from "./features/statistics/ProblemStats"; import Main, { Menubutton } from "./features/navigation/main"; import ProbGen from "./features/problems/ProblemGenerator"; import ProbTime from "./features/problems/ProblemTime"; +import SkipReason from "./features/problems/SkipReason"; import Settings from "./features/settings/settings"; import TimerBanner from "./components/timer/timercomponent"; // Removed Mantine CSS import - not needed in content script @@ -252,6 +253,19 @@ const Router = () => { } /> + + + + + + } + /> { - if (chrome?.runtime?.sendMessage) { - chrome.runtime.sendMessage({ - type: "skipProblem", - consentScriptData: routeState?.problemData, - }); - } - navigate("/Probgen"); + navigate("/SkipReason", { + state: { + problemData: problemData, + }, + }); }; return { handleNewAttempt, handleSkip }; diff --git a/chrome-extension-app/src/content/features/problems/SkipReason.jsx b/chrome-extension-app/src/content/features/problems/SkipReason.jsx new file mode 100644 index 00000000..e4b78bd7 --- /dev/null +++ b/chrome-extension-app/src/content/features/problems/SkipReason.jsx @@ -0,0 +1,168 @@ +import { useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import "../../css/probrec.css"; +import Header from "../../components/navigation/header"; +import { useNav } from "../../../shared/provider/navprovider"; +import { useAnimatedClose } from "../../../shared/hooks/useAnimatedClose"; +import Button from "../../components/ui/Button.jsx"; +import ChromeAPIErrorHandler from "../../../shared/services/chrome/chromeAPIErrorHandler.js"; +import logger from "../../../shared/utils/logging/logger.js"; + +const SKIP_REASONS = [ + { + value: 'too_difficult', + label: 'Too difficult', + description: 'This problem is above my current skill level', + emoji: '🔥' + }, + { + value: 'dont_understand', + label: "Don't understand", + description: 'The problem statement or concept is unclear', + emoji: '❓' + }, + { + value: 'not_relevant', + label: 'Not relevant', + description: "This problem doesn't fit my learning goals", + emoji: '🎯' + }, + { + value: 'other', + label: 'Other', + description: 'Another reason', + emoji: '💭' + }, +]; + +const SkipReasonOption = ({ reason, isSelected, onSelect }) => ( + +); + +function SkipReason() { + const { isAppOpen, setIsAppOpen } = useNav(); + const { shouldRender, isClosing } = useAnimatedClose(isAppOpen); + const { state: routeState } = useLocation(); + const navigate = useNavigate(); + + const [selectedReason, setSelectedReason] = useState(null); + const [otherText, setOtherText] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + const problemData = routeState?.problemData; + const problemTitle = problemData?.title || 'this problem'; + + const handleClose = () => { + setIsAppOpen(false); + }; + + const handleBack = () => { + navigate(-1); + }; + + const handleSubmit = async () => { + if (!selectedReason || isSubmitting) return; + + setIsSubmitting(true); + + try { + const response = await ChromeAPIErrorHandler.sendMessageWithRetry({ + type: "skipProblem", + leetcodeId: problemData?.leetcode_id, + problemData: problemData, + skipReason: selectedReason, + otherText: selectedReason === 'other' ? otherText : null, + }); + + logger.info("Skip problem response:", response); + + // Navigate back to generator - the prerequisite (if found) is now in the session + if (response?.prerequisite && response?.replaced) { + logger.info("Prerequisite added to session:", response.prerequisite.title); + } + // Always go back to generator to show updated problem list + navigate("/Probgen"); + } catch (error) { + logger.error("Error skipping problem:", error); + // Still navigate away on error + navigate("/Probgen"); + } finally { + setIsSubmitting(false); + } + }; + + if (!shouldRender) return null; + + return ( +
+
+
+
+

+ Why are you skipping {problemTitle}? +

+ +
+ {SKIP_REASONS.map((reason) => ( + + ))} +
+ + {selectedReason === 'other' && ( +