Repository: https://github.com/matthewdeaves/cookie Commit: cfaafa8 Date: 2026-01-25 Scope: apps/legacy/static/legacy/js/ (ES5 JavaScript for iOS 9 support)
This review was generated by Claude Opus 4.5 through direct analysis of the codebase and metrics collected by the CI dashboard (GitHub Actions: ESLint, duplication detection).
This review examines the legacy ES5 JavaScript frontend built for iOS 9 compatibility. The constraints are different from modern JavaScript - no modules, no async/await, no modern build tools. But the architectural patterns are strikingly similar to the modern React frontend, suggesting the same underlying planning issues.
# Executive Summary
The legacy codebase is 4,623 lines of vanilla ES5 JavaScript split across 9 page modules. Code structure mirrors the modern frontend's problems: god components, duplicated utilities, and monolithic files that mix concerns. The 6.67% duplication metric is actually conservative - it only measures text duplication, not the architectural duplication evident in the code.
Critical issues:
- detail.js god component (1,275 lines, manages recipe display, favorites, collections, AI scaling, remix, tips, polling, play mode)
- settings.js god component (1,100 lines, manages 6 tabs with API keys, prompts, sources, profiles, database reset)
- Global namespace pollution (8+ functions exposed on window for inline onclick handlers)
- No shared utilities (escapeHtml() reimplemented 5 times across files)
Moderate issues:
- Event listener attachment anti-pattern (16+ loops attaching listeners instead of delegation)
- Large HTML string concatenation (no templating, maintenance nightmare)
- Callback hell in async operations (nested AJAX callbacks 3-4 levels deep)
- Variable shadowing risk (loop variables reused across 16+ loops in settings.js)
- DOM querying inefficiency (repeated queries with no caching)
ES5 constraints:
- No module system makes utility sharing impossible without global namespace pollution or async module loading
- No template literals makes HTML generation verbose
- No Promises means callback nesting is unavoidable
- No const/let means variable shadowing risk is higher
# 1. Architecture & Structure
# File Size Distribution
| File | Lines | Purpose |
|---|---|---|
| detail.js | 1,275 | Recipe detail screen |
| settings.js | 1,100 | Settings screen with 6 tabs |
| search.js | 603 | Search screen |
| play.js | 587 | Play mode screen |
| home.js | 376 | Home screen |
| profile-selector.js | 237 | Profile selection |
| collection-detail.js | 189 | Collection detail |
| collections.js | 154 | Collections list |
| favorites.js | 102 | Favorites list |
Pattern: Two files (detail.js and settings.js) contain half the codebase. This mirrors the modern frontend where Settings.tsx (1,314 lines) and RecipeDetail.tsx (739 lines) dominate.
# God Component: detail.js (1,275 lines)
Location: apps/legacy/static/legacy/js/pages/detail.js
What it manages:
- Recipe display with 4 tabs (ingredients, instructions, nutrition, tips)
- Favorite toggling
- Collection management (add/remove recipe)
- Serving adjustment with AI-powered ingredient scaling
- Remix functionality with AI suggestions
- Tips generation via AI
- Polling for async AI tips
- Navigation to play mode
- Modal management for collections and remix
- Error handling for AI features
- Form state for serving input and remix text area
State variables (defined in module scope):
var currentRecipeData = null;
var currentIngredients = null;
var originalIngredients = null;
var tipsPollingInterval = null;
var favoriteBtn = null;
var collectionsContainer = null;
// ... 20+ more variables Problem: Violates single responsibility principle. One file handles everything from basic recipe display to complex AI polling and modal interactions. Testing a single feature requires understanding the entire 1,275-line file.
Example complexity - serving adjustment spans 50+ lines mixing DOM manipulation, validation, AI calls, error handling, and state updates without clear separation.
# God Component: settings.js (1,100 lines)
Location: apps/legacy/static/legacy/js/pages/settings.js
What it manages:
- Tab navigation (6 tabs: general, prompts, sources, selectors, users, danger)
- API key validation and persistence
- Prompt editing for 4 different prompt types (discover, remix, scale, tips)
- Source enable/disable toggles (individual and bulk)
- CSS selector editing and testing (individual and batch)
- Profile management (list, delete with preview modal)
- Database reset (two-step confirmation with text input validation)
Initialization pattern (lines 130-132, repeated 16+ times):
for (var i = 0; i < testSourceBtns.length; i++) {
testSourceBtns[i].addEventListener('click', function(e) {
// handler logic
});
} Problem: Attaching listeners in loops instead of using event delegation. Every tab uses this pattern:
- Lines 143-146: test batch source buttons
- Lines 259-261: toggle source buttons
- Lines 433-436: prompt edit buttons
- Lines 445-448: prompt save buttons
- Lines 471-473: prompt toggle buttons
- Lines 494-496: individual source test buttons
- Lines 524-526: individual source toggle buttons
- Lines 548-550: batch test buttons
- Lines 596-598: edit selector buttons
- Lines 601-603: save selector buttons
- Lines 606-608: test individual selector buttons
- Lines 611-613: test batch selector buttons
- Lines 758-761: delete profile buttons
- Lines 812-814: reset modal buttons
- Lines 847-849: execute reset buttons
Better approach: Single delegated listener on the container:
// Instead of 16 loops
document.addEventListener('click', function(e) {
if (e.target.matches('[data-action="toggle-source"]')) {
handleToggleSource(e);
}
// ... other actions
}); This would reduce 16 loops to 1 event listener, improve performance, and handle dynamically added elements automatically.
# Global Namespace Pollution
Location: apps/legacy/static/legacy/js/pages/settings.js lines 1077-1097
Problem: Functions exposed globally for inline onclick handlers:
window.closeDeleteModal = closeDeleteModal;
window.executeDeleteProfile = executeDeleteProfile;
window.showResetModal = showResetModal;
window.showResetStep1 = showResetStep1;
window.showResetStep2 = showResetStep2;
window.closeResetModal = closeResetModal;
window.executeReset = executeReset;
window.closeTestModal = closeTestModal;
window.renderSourceCard = renderSourceCard; Why it exists: HTML uses inline event handlers:
<button onclick="closeDeleteModal()">Cancel</button>
<button onclick="executeDeleteProfile()">Confirm</button> Risk: Global namespace collisions. If another page module defines closeDeleteModal, the last one loaded wins. No warning, just silent bugs.
Better approach: Use data attributes and delegated listeners (as suggested for the loop pattern above). Removes need for global exposure entirely.
# 2. Code Duplication
# escapeHtml() - Reimplemented 5 Times
Locations:
Implementation (identical in all 5 files):
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
} Why it's duplicated: ES5 has no module system. Each page file is self-contained. Options are:
- Duplicate the function (current approach)
- Put it in global scope (namespace pollution)
- Use async module loading like RequireJS (adds complexity)
Impact: This is why the 6.67% duplication metric is actually conservative. The architectural duplication (god components, repeated patterns) isn't captured by text duplication detection.
# handleTabClick() - Reimplemented 3 Times
Locations:
Pattern (similar structure in all 3):
function handleTabClick(e) {
e.preventDefault();
var clickedTab = e.target.closest('.tab-btn');
if (!clickedTab) return;
var allTabs = document.querySelectorAll('.tab-btn');
var allPanels = document.querySelectorAll('.tab-panel');
for (var i = 0; i < allTabs.length; i++) {
allTabs[i].classList.remove('active');
}
for (var i = 0; i < allPanels.length; i++) {
allPanels[i].classList.remove('active');
}
clickedTab.classList.add('active');
var targetPanel = document.getElementById(clickedTab.dataset.tab);
if (targetPanel) targetPanel.classList.add('active');
} Problem: Same DOM manipulation logic repeated across 3 files. Each file implements tabs independently.
Why it happened: No shared component system in ES5. Each screen handles its own tab behaviour.
# init() Function - Present in 9 Page Modules
Each page file exports an init() function that:
- Queries and caches DOM elements
- Attaches event listeners
- Loads initial data
Pattern (repeated 9 times):
function init() {
// Cache DOM elements
var apiKeyInput = document.getElementById('api-key-input');
var testKeyBtn = document.getElementById('test-key-btn');
// ... 20+ more queries
// Attach event listeners
testKeyBtn.addEventListener('click', handleTestKey);
// ... 30+ more listeners
// Load data
loadInitialData();
} Problem: Every page follows this pattern but implements it from scratch. No shared initialization framework.
# 3. HTML Generation
# Large String Concatenation
Location: settings.js:450-464
Example (building source card HTML):
var toggleIcon = source.is_enabled
? '<svg class="toggle-icon toggle-on" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><path d="M9 12l2 2 4-4"></path></svg>'
: '<svg class="toggle-icon toggle-off" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle></svg>';
html += '<div class="source-card" data-source-id="' + source.id + '">';
html += ' <div class="source-header">';
html += ' <h3>' + escapeHtml(source.domain) + '</h3>';
html += ' <button type="button" class="btn-toggle" data-action="toggle-source" data-source-id="' + source.id + '">';
html += ' ' + toggleIcon;
html += ' </button>';
html += ' </div>';
// ... 20+ more lines of concatenation Problems:
- No syntax highlighting or editor support for HTML
- Easy to forget closing tags
- Mixing data (source.id) with markup makes it hard to spot XSS risks
- Changing UI requires editing long string concatenation
- No template reuse - each card is built from scratch
ES5 constraints: No template literals, no JSX, no modern templating engines client-side.
Better approach (within ES5 constraints): Use a micro-templating function or HTML5 template elements:
// Define template once
var sourceCardTemplate = document.getElementById('source-card-template');
function renderSourceCard(source) {
var clone = sourceCardTemplate.content.cloneNode(true);
clone.querySelector('.source-name').textContent = source.domain;
clone.querySelector('[data-action="toggle-source"]').dataset.sourceId = source.id;
// ... set other values
return clone;
} This separates markup (in HTML) from logic (in JavaScript), making both easier to maintain.
# 4. Async Operations
# Callback Hell
Location: detail.js
Example (collection management with nested callbacks):
Cookie.ajax.post('/api/collections/', { name: name }, function(err, collection) {
if (err) {
Cookie.toast.error('Failed to create collection');
return;
}
// Nested callback level 1
Cookie.ajax.post('/api/collections/' + collection.id + '/recipes/',
{ recipe_id: recipeId },
function(err2) {
if (err2) {
Cookie.toast.error('Failed to add recipe');
return;
}
// Nested callback level 2
Cookie.ajax.get('/api/collections/', function(err3, collections) {
if (err3) {
Cookie.toast.error('Failed to reload collections');
return;
}
// Finally update UI
updateCollectionsUI(collections);
});
}
);
}); Problem: Three levels of nesting for a single user action (create collection, add recipe, refresh list). Error handling repeated at each level. Logic flow is hard to follow.
ES5 constraint: No Promises, no async/await. Callbacks are the only option.
Better approach (within ES5 constraints): Extract named functions to flatten nesting:
function createCollection(name, recipeId) {
Cookie.ajax.post('/api/collections/', { name: name }, function(err, collection) {
if (err) return handleError('Failed to create collection');
addRecipeToCollection(collection.id, recipeId);
});
}
function addRecipeToCollection(collectionId, recipeId) {
Cookie.ajax.post('/api/collections/' + collectionId + '/recipes/',
{ recipe_id: recipeId },
function(err) {
if (err) return handleError('Failed to add recipe');
refreshCollections();
}
);
}
function refreshCollections() {
Cookie.ajax.get('/api/collections/', function(err, collections) {
if (err) return handleError('Failed to reload collections');
updateCollectionsUI(collections);
});
} Still callbacks, but each function has a single level of nesting. Logic flow is clearer.
# 5. DOM Manipulation
# Repeated Queries Without Caching
Location: settings.js:338-339 and throughout
Example (querying same card element multiple times):
card.querySelector('[data-field="system_prompt"]').value = prompt.system_prompt;
card.querySelector('[data-field="user_prompt_template"]').value = prompt.user_prompt_template;
card.querySelector('[data-field="model"]').value = prompt.model;
card.querySelector('[data-field="is_enabled"]').checked = prompt.is_enabled; Problem: Each querySelector traverses the DOM. For 4 fields, that's 4 separate DOM queries on the same parent.
Better approach:
var fields = {
systemPrompt: card.querySelector('[data-field="system_prompt"]'),
userPrompt: card.querySelector('[data-field="user_prompt_template"]'),
model: card.querySelector('[data-field="model"]'),
enabled: card.querySelector('[data-field="is_enabled"]')
};
fields.systemPrompt.value = prompt.system_prompt;
fields.userPrompt.value = prompt.user_prompt_template;
fields.model.value = prompt.model;
fields.enabled.checked = prompt.is_enabled; Query once, reuse references. Reduces DOM traversal by 75%.
# Variable Shadowing Risk
Location: settings.js throughout
Problem: Loop variable i used in 16+ loops within the same module scope:
for (var i = 0; i < testSourceBtns.length; i++) { ... }
// ... 50 lines later
for (var i = 0; i < toggleSourceBtns.length; i++) { ... }
// ... 100 lines later
for (var i = 0; i < sources.length; i++) { ... } Why it's risky: var is function-scoped, not block-scoped. All these i variables are actually the same variable being reused. If loops are nested (they sometimes are), inner loop clobbers outer loop's counter, causing bugs.
ES5 constraint: No let or const for block scoping.
Mitigation: Use IIFEs to create scope or use different variable names (i, j, k, idx, etc.) to make shadowing obvious.
# 6. ESLint Warnings (17 warnings, C rating)
# Complexity Rules
Configuration (.eslintrc.js):
rules: {
'complexity': ['warn', 15], // max cyclomatic complexity
'max-depth': ['warn', 4], // max nesting depth
'max-lines-per-function': ['warn', 100], // max lines per function
'max-nested-callbacks': ['warn', 4], // max callback nesting
} Where rules are breached:
- settings.js functions regularly exceed 100 lines (setupSelectorsTab, renderSourceCard)
- detail.js has functions with complexity > 15 (serving adjustment, remix handling)
- Callback nesting reaches 3-4 levels in async operations
# Quality Rules
Configuration:
'no-unused-vars': ['warn', { args: 'none' }], // warn on unused variables
'no-undef': 'error', // error on undefined globals
'no-shadow': 'warn', // warn on variable shadowing
'eqeqeq': ['warn', 'smart'], // warn on == instead of === Issues caught:
- Unused variables (likely from refactoring)
- Undefined globals (missing declarations)
- Variable shadowing (the loop variable
ireuse pattern)
Impact: The 17 warnings are symptoms of the larger architectural issues. Fixing god components and extracting functions would reduce complexity naturally.
# 7. What to Improve
# Critical Fixes
- Extract settings.js tabs into separate modules
Current: 1,100-line file with 6 tabs inline Target: 6 modules (settings-general.js, settings-prompts.js, etc.) loaded on demand
Benefits:
- Reduces per-file complexity
- Makes tab logic testable in isolation
- Easier to understand and modify individual tabs
Trade-off: Need async module loading (RequireJS or similar), adds build complexity
- Extract detail.js features into separate modules
Current: 1,275-line file handling display, favorites, collections, AI features, polling Target: Split into detail-display.js, detail-ai.js, detail-collections.js
Benefits: Same as settings.js modularisation
- Create shared utilities module
Extract duplicated functions into Cookie.utils:
escapeHtml()(duplicated 5 times)handleTabClick()(duplicated 3 times)formatTime()(similar implementations in multiple files)
Load utilities before page modules, expose on Cookie namespace.
# Moderate Fixes
- Replace event listener loops with delegation
Current: 16+ loops in settings.js attaching individual listeners Target: Single delegated listener per container
Example:
// Replace all 16 loops with:
document.getElementById('settings-container').addEventListener('click', function(e) {
var action = e.target.dataset.action;
if (!action) return;
var handlers = {
'toggle-source': handleToggleSource,
'test-source': handleTestSource,
'edit-prompt': handleEditPrompt,
// ... all actions
};
var handler = handlers[action];
if (handler) handler(e);
}); Benefits:
- Reduces listener count from 100+ to 1
- Handles dynamically added elements automatically
- Removes need for global function exposure
- Replace HTML string concatenation with template elements
Move HTML structure from JavaScript strings to HTML5 <template> elements. Clone and populate templates instead of building strings.
Benefits:
- Editor syntax highlighting for HTML
- Easier to spot missing closing tags
- Clearer separation of markup and logic
- Flatten callback nesting
Extract nested callbacks into named functions (example shown in Async Operations section).
Benefits:
- Easier to follow logic flow
- Reduces indentation depth
- Makes error handling consistent
# Process Fixes
- Specify module boundaries in plans
Instead of "build settings screen", write "build settings screen with separate modules for each tab: general (API keys), prompts (4 types), sources (enable/disable), selectors (CSS editing), users (profile CRUD), danger (reset)".
This prevents 1,100-line files from being created in the first place.
- Require event delegation pattern
Plans for ES5 JavaScript should specify: "Use event delegation for dynamic elements. Attach single listener to container, check event.target for specific elements."
This prevents the 16-loop pattern.
- Create shared utilities first
Before building page modules, create Cookie.utils with common functions (escapeHtml, formatTime, etc.). Reference it in page module tasks: "Use Cookie.utils.escapeHtml() for HTML escaping."
This prevents duplication from the start.
# Summary
The 6.67% duplication metric is honest but incomplete. It captures text duplication (escapeHtml reimplemented 5 times) but not architectural duplication (god components, repeated patterns, no shared abstractions).
The ES5 constraints are real - no modules, no Promises, no template literals. But within those constraints, better patterns exist:
- God components can be split into smaller modules with async loading
- Event listener loops can be replaced with delegation
- HTML string concatenation can use template elements
- Callback hell can be flattened with named functions
- DOM queries can be cached
The legacy code mirrors the modern frontend's problems: settings.js (1,100 lines) matches Settings.tsx (1,314 lines), detail.js (1,275 lines) matches RecipeDetail.tsx (739 lines). The planning issues are the same regardless of technology choice.