Simpler Offline Scoring with Turbo MVC
Two weeks ago I documented building an offline-first scoring SPA with Web Components. The implementation worked: judges could score during network outages, dirty scores queued in IndexedDB, everything synced when connectivity returned.
But the complexity nagged at me. ~4,000 lines of JavaScript for a scoring interface—3.2x more code than the original ERB/Stimulus implementation (1,258 lines). The Web Components approach added structure and encapsulation, but did I really need Shadow DOM and custom element lifecycles for template rendering and offline queueing?
The question became: What's the simplest architecture that delivers offline resilience?
This post documents an experimental spike exploring a different approach—intercepting Turbo navigation, rendering with template strings, and using a minimal Service Worker. The results surprised me, and Claude Code was instrumental in exploring the solution space.
The Complexity Problem
The Web Components SPA (commit 188f97d) delivered offline capability but at 4,025 lines (3.2x the original 1,258-line ERB/Stimulus implementation).
The overhead wasn't just line count—it was architectural complexity:
1. Shadow DOM Boundaries
- Debugging required understanding component encapsulation
- Data flow through custom events and attributes
- Lifecycle management (connectedCallback, disconnectedCallback, attributeChangedCallback)
2. Complex Data Manager (334 lines)
- IndexedDB with version checks
- Batch upload synchronization
- Offline queue management
3. Development Experience
- Hot reload unreliable (server restarts and hard refreshes required)
- Foreign patterns for Rails developers
- Lost MVC clarity
The question became: Is this complexity necessary for offline capability?
The Hypothesis: Turbo MVC
What if offline scoring followed traditional Rails patterns?
Model: In-memory data + IndexedDB for offline queue View: Template strings (like ERB, but JavaScript) Controller: Client-side rendering coordinator
The key insight: Turbo fires events before navigation. We can intercept them:
document.addEventListener('turbo:before-visit', (event) => {
const url = new URL(event.detail.url)
if (url.pathname.includes('/turbo-spike')) {
event.preventDefault() // Cancel server visit
renderFromMemory(url) // Render client-side
history.pushState({}, '', url.pathname) // Update URL
}
})
This unlocks offline navigation without a complex SPA framework. Turbo thinks it's doing regular navigation, but we intercept and serve from memory.
Add a simple Service Worker for caching API responses during network failures, and you have offline resilience with minimal code.
Enter Claude Code
This is where Claude Code became essential. I had the hypothesis but needed to validate it quickly. Could I spike a working implementation in hours rather than days?
I opened Claude Code and described the problem:
"I want to evaluate a radically different approach. What if the SPA were organized using Rails MVC patterns client-side? Model would be in-memory data. Views would be template strings. Controller would coordinate rendering. Can we intercept Turbo navigation and render client-side?"
Claude immediately grasped the architecture and started building:
Phase 1: Navigation Spike (3 hours)
Claude generated:
turbo_client.js- Turbo interception and navigationheat_model.js- Simple data layer (fetch + cache)turbo_spike_controller.js- Stimulus orchestrator- Routes and controller action
The breakthrough: It worked immediately. Claude understood Turbo's event model, wrote the interception logic correctly, and wired it all together.
When I tested offline (server stopped), navigation between heats kept working. The core assumption was proven in 3 hours.
Phase 2: Scoring Implementation (2 hours)
Next challenge: actual scoring interface. Claude extended the implementation:
renderScoringTable(heat, scoreOptions, data) {
return `
<table class="w-full border-collapse">
<thead>
<tr>
<th>Back</th>
<th>Lead</th>
<th>Follow</th>
${scoreOptions.map(s => `<th>${s}</th>`).join('')}
</tr>
</thead>
<tbody>
${heat.subjects.map(subject =>
this.renderSubjectRow(subject, scoreOptions, judgeData)
).join('')}
</tbody>
</table>
`
}
Template strings felt immediately familiar—like ERB but in JavaScript. No JSX transpilation, no build step, just string interpolation.
Claude added:
- Radio button rendering
- Score POST to server
- In-memory score updates for persistence
- Visual feedback (green flash on save)
Total implementation: 689 lines across 5 files. And it worked.
Phase 3: Layout Matching
When I tested in the browser, I noticed the layout looked different from the ERB views. I told Claude:
"The content is in a single column occupying the leftmost 1/3 of the page instead of the full width."
Claude immediately diagnosed the issue: the layout wrapper was constraining width. It added @layout = 'mx-0 px-5' to the controller and regenerated the rendering methods to match the ERB structure exactly—full-screen flexbox, large centered header, info box, navigation footer.
The result looked identical to the production ERB views.
Refactoring: Breaking Up a 902-Line File
After implementing solo heat support, turbo_client.js had grown to 902 lines. I mentioned my concern about file size.
Claude proposed an immediate refactoring:
- Extract heat-specific renderers into separate modules
- Keep core navigation and score management in
turbo_client.js - Create
renderers/table_heat_renderer.jsandrenderers/solo_heat_renderer.js
In minutes, Claude generated:
- 3 focused modules (345, 119, 313 lines)
- Total: 777 lines (14% reduction + much better organization)
- All imports wired correctly
- Everything still working
The refactored code was cleaner, more maintainable, and set up perfectly for adding remaining heat types (finals with drag-drop, cards).
What Claude Code Enabled
Speed: What would have taken me 2-3 days of exploratory coding took 5 hours of collaborative iteration with Claude. The spike answered the core questions in a single afternoon.
Architectural exploration: Claude helped me evaluate the approach without committing to a full rewrite. We built just enough to validate the hypothesis.
Pattern recognition: When I described "Turbo navigation interception," Claude immediately understood the event model and wrote correct code. It recognized Rails MVC patterns and mapped them to client-side equivalents.
Layout matching: When I pointed out visual differences, Claude examined the ERB partials, identified the layout variable, and regenerated rendering methods to match exactly.
Proactive refactoring: Claude suggested breaking up the 902-line file before it became unmanageable, and executed the refactoring cleanly.
No hand-holding needed: I could focus on architecture and validation while Claude handled implementation details.
This isn't "Claude wrote all the code"—it's "Claude accelerated the exploration so I could validate architectural choices quickly." That speed is invaluable when evaluating alternatives.
The Results: Complexity Comparison
After systematic refactoring across three sprints, here's the final implementation:
Turbo MVC (Final, Refactored)
Total: 2,604 lines for complete implementation with all heat types
Renderers (1,084 lines):
- Base renderer (template method pattern): 91 lines
- Table heat (radio buttons): 117 lines
- Solo heat (formations, comments): 323 lines
- Finals/ranking (drag-and-drop): 254 lines
- Cards (drag-and-drop between columns): 309 lines
Core Infrastructure (768 lines):
- TurboClient (navigation, score management): 658 lines
- Heat model (data layer): 86 lines
- Connection status component: 79 lines
Utilities (557 lines):
- Dirty scores queue (IndexedDB): 230 lines
- Subject sorter: 111 lines
- Score helper: 85 lines
- Renderer registry: 75 lines
- UI feedback: 76 lines
- Event listener manager: 42 lines
- Configuration constants: 68 lines
vs. Web Components SPA
Total: ~4,025 lines (full implementation)
- All components (table, solo, cards, finals): ~3,700 lines
- Data manager (IndexedDB, version checks, batch sync): ~334 lines
- Navigation and shared components: ~650 lines
Turbo MVC achieves:
- 1,421 fewer lines (-35% code reduction)
- 3x simpler data layer (86 + 230 = 316 vs 334 lines, but far less complex)
- Better architecture (template method, registry pattern, shared utilities)
vs. Original ERB/Stimulus
Total: ~1,258 lines (no offline support)
- All ERB views (table, solo, cards, finals): ~721 lines
score_controller.js: ~537 lines
Turbo MVC adds:
- +1,346 lines (+107%) to gain complete offline capability
- But only 35% of what Web Components added (Web Components: +2,767 lines)
The Remarkable Result
Turbo MVC: 2,604 lines vs Web Components: 4,025 lines
That's 35% less code for the same offline capability. But the win isn't just size—it directly addresses each complexity problem:
1. No Shadow DOM → Simpler Architecture
- Template strings instead of custom elements
- Direct DOM manipulation (no encapsulation overhead)
- Template method pattern for shared structure (91-line base class)
- Registry pattern for renderer selection (75 lines)
2. Simpler Data Layer (316 vs 334 lines)
- Heat model: 86 lines (fetch + cache)
- Dirty scores queue: 230 lines (IndexedDB, no version checks/batch complexity)
- Shared utilities: ScoreHelper (85 lines), SubjectSorter (111 lines)
3. Better Development Experience
- Hot reload works reliably (template strings, no Shadow DOM)
- Familiar Rails patterns (MVC structure, template strings like ERB)
- Centralized configuration (68-line constants file)
- Comprehensive JSDoc (improved maintainability)
Code increase comparison:
- Web Components: +2,767 lines (+220%) for offline support
- Turbo MVC: +1,346 lines (+107%) for offline support
The refactoring delivered production-quality code while maintaining 100% test compatibility throughout.
What This Looks Like
Template rendering (familiar to Rails developers):
renderSoloContent(subject, score) {
const dancers = this.getDancersDisplay(subject)
const studio = this.getStudioName(subject)
const comments = score?.comments || ''
return `
<div class="mb-4">
<div><b>Studio</b>: ${studio}</div>
<div><b>Names</b>: ${dancers}</div>
</div>
<label><b>Comments:</b></label>
<textarea data-heat-id="${subject.id}">${comments}</textarea>
<div class="mt-4">
<input type="number" value="${score?.value || ''}"
min="0" max="100" />
</div>
`
}
Event handling (straightforward):
attachListeners(target) {
target.querySelectorAll('input[type="radio"]').forEach(radio => {
radio.addEventListener('change', (e) => this.handleScoreChange(e))
})
}
Offline support (minimal Service Worker):
// Network-first strategy with fallback to cache
self.addEventListener('fetch', (event) => {
event.respondWith(
fetch(event.request)
.then(response => {
// Cache successful GET responses
if (response.status === 200) {
cache.put(event.request, response.clone())
}
return response
})
.catch(() => caches.match(event.request)) // Use cache on failure
)
})
No Shadow DOM. No component lifecycle. No complex data manager. Just fetch, render, attach listeners, repeat.
The architecture delivers everything Web Components promised, with half the complexity:
- Offline resilience: IndexedDB queue + Service Worker
- Progressive enhancement: ERB views still work, Turbo MVC is opt-in
- Standard technologies: Turbo, template strings, import maps—no framework lock-in
- Modular structure: Clear renderer boundaries, easy to test
- Familiar patterns: Rails MVC translated to client-side
The Role of AI-Assisted Development
Claude Code didn't replace thinking—it accelerated exploration. I designed the architecture, identified the hypothesis, and validated the approach. Claude implemented it fast enough to test ideas in hours rather than days.
Key moments where Claude was essential:
- Turbo navigation interception - I described the concept, Claude wrote correct code immediately
- Template rendering - Claude generated ERB-like templates that worked on first try
- Layout matching - Claude examined ERB partials and regenerated matching structure
- Refactoring - Claude proactively suggested modularization before file size became a problem
- Score management - Claude handled in-memory updates, POST logic, visual feedback
What I brought:
- Problem understanding (offline scoring needs)
- Architectural hypothesis (Turbo MVC pattern)
- Rails/Hotwire knowledge (how Turbo events work)
- Judgment (is this worth pursuing?)
What Claude brought:
- Rapid prototyping (5 hours vs 2-3 days)
- Implementation accuracy (code worked on first try)
- Pattern recognition (Rails MVC → client-side equivalent)
- Proactive suggestions (refactoring before problems emerge)
This is collaborative programming at its best. AI handles mechanical implementation while humans handle design and judgment.
For more on working effectively with Claude, see my earlier post on Disciplined Claude Usage.
From Spike to Production Quality
The journey from initial prototype to production-ready code demonstrates how systematic refactoring—assisted by Claude Code—can transform rapid exploration into maintainable software.
Phase 1: Rapid Prototyping (5 hours)
Initial spike proved the concept:
- Turbo navigation interception works offline
- Template strings sufficient for all heat types
- Simple patterns deliver offline capability
Result: ~1,900 lines of working code
Phase 2: Feature Completion (1 day)
Implemented all four heat types:
- Table scoring with radio buttons
- Solo scoring with formations and comments
- Finals with drag-and-drop ranking
- Cards with drag-and-drop between score columns
- Full IndexedDB offline queue integration
Result: Complete feature parity with Web Components SPA
Phase 3: Systematic Refactoring (1 day)
Claude Code identified 36+ refactoring opportunities and executed three sprints:
Sprint 1 - Code Deduplication:
- Template method pattern (BaseHeatRenderer)
- Shared utilities (ScoreHelper, SubjectSorter)
- Eliminated ~400+ lines of duplication
Sprint 2 - Configuration & Documentation:
- Centralized constants
- UI feedback utilities
- Event listener management
- Comprehensive JSDoc
Sprint 3 - Architecture Improvements:
- Renderer registry pattern
- Simplified TurboClient
- Better separation of concerns
Result: 2,604 lines of production-quality, well-documented code
What This Achieved
Better than initial spike:
- +704 lines for utilities, documentation, and architecture
- But eliminated ~400 lines of duplication
- Net result: cleaner, more maintainable code
Dramatically better than Web Components:
- 1,421 fewer lines (-35% code reduction)
- Simpler architecture (no Shadow DOM, no component lifecycle)
- Better organized (shared utilities, template method pattern)
The refactoring didn't just clean up code—it improved the architecture while maintaining 100% test compatibility throughout.
How Claude Code Enabled Systematic Refactoring
This wasn't just "clean up the code"—it was architectural transformation with zero regressions.
Claude's approach:
- Analyzed entire codebase and identified 36+ refactoring opportunities
- Categorized by risk (low/medium/high) and priority (critical/important/nice-to-have)
- Organized into three achievable sprints with clear goals
- Executed each sprint while maintaining 100% test compatibility
- Proactively improved test quality (silenced 16 error-handling tests for clean output)
The remarkable part: All 500 tests passed throughout. Every refactoring maintained perfect behavioral compatibility.
What this demonstrates:
- AI doesn't just write code—it can systematically improve architecture
- Refactoring with zero regressions across 2,604 lines
- The discipline to maintain test compatibility while transforming structure
- Proactive identification of issues before they become problems
Traditional refactoring requires:
- Manual code review (hours)
- Identify patterns (more hours)
- Plan refactoring (risk assessment)
- Implement carefully (days, watching for breakage)
- Test exhaustively (pray nothing broke)
With Claude Code:
- Request analysis → get 36 prioritized opportunities
- Approve plan → three sprints executed
- Watch tests → 500/500 passing throughout
- Result → production-quality code in a day
This is the real power of AI-assisted development: not just rapid prototyping, but systematic quality improvement at unprecedented speed.
Why This Approach Was Abandoned
The Turbo MVC spike achieved complete feature parity with the Web Components SPA—146 passing tests covering all documented functionality. The code was clean, well-architected, and 35% smaller than the Web Components implementation.
But there was a fundamental problem: template duplication.
The Template Maintenance Problem
The spike successfully implemented all four heat renderers using JavaScript template strings:
// JavaScript template (Turbo MVC)
renderSoloContent(subject, score) {
return `
<div class="mb-4">
<div><b>Studio</b>: ${this.getStudioName(subject)}</div>
<div><b>Names</b>: ${this.getDancersDisplay(subject)}</div>
</div>
`
}
But this meant maintaining parallel template implementations alongside the existing ERB views:
<%# ERB template (original) %>
<div class="mb-4">
<div><b>Studio</b>: <%= subject.entry.studio.name %></div>
<div><b>Names</b>: <%= names(subject) %></div>
</div>
The problems:
- Logic divergence: Helper methods (
names()) vs. JavaScript methods (getDancersDisplay()) - Double maintenance: Every template change requires updates in two places
- Drift risk: Easy to miss edge cases when reimplementing Ruby logic in JavaScript
- Testing burden: Need parallel test suites to verify behavioral parity
The Tests Were Stubs
Despite 146 passing tests, deeper examination revealed they were behavior stubs—they verified the structure of rendered output but not its correctness:
// Test verified structure, not actual content
test('renders studio name', () => {
const html = renderer.render(mockData);
expect(html).toContain('<b>Studio</b>:');
// But didn't verify the studio name was correct!
});
The tests passed because they checked for HTML elements, not whether those elements contained the right data. Real verification would require comparing against ERB output—essentially testing that two separate template systems produce identical results.
What the Spike Revealed
The Turbo MVC approach successfully proved offline capability didn't require Web Components. But it also clarified the real problem wasn't architectural complexity—it was template duplication.
Both approaches (Web Components and Turbo MVC) required:
- Reimplementing ERB templates in JavaScript
- Translating Ruby helper methods to JavaScript equivalents
- Maintaining behavioral parity manually
- Testing two separate implementations
The spike wasn't wasted effort—it compressed the solution space by revealing what actually needed solving.
The Path Forward
Instead of maintaining parallel templates, the next approach inverted the problem: What if ERB templates were automatically converted to JavaScript?
This led to the ERB-to-JavaScript conversion approach, which delivers:
- Single template codebase (ERB is the source of truth)
- Guaranteed parity (same template, same output)
- 66% less hand-written code than Web Components (1,370 vs 4,025 lines)
- Same bandwidth efficiency (bulk JSON download, client-side rendering)
- Battle-tested by design (ERB templates proven in production)
The Turbo MVC spike proved offline capability was achievable with simple patterns. But it also proved template duplication was the bottleneck, not architectural complexity.
Lessons From the Spike
1. Production-Ready ≠ Production-Suitable
The Turbo MVC code was high quality:
- Clean architecture (template method, registry pattern)
- Comprehensive tests (146 passing)
- Well-documented (JSDoc throughout)
- Systematically refactored (36+ improvements)
But quality code can solve the wrong problem. Template duplication would create maintenance burden regardless of code quality.
2. Tests Can Be Misleading
146 passing tests suggested completeness, but the tests were structural stubs. They verified HTML structure, not correctness. Real behavioral parity testing would require:
- Comparing ERB output with JavaScript output
- Identical data structures
- Matching helper method behavior
- Edge case verification
That's effectively what render_erb_and_js.rb now does for the ERB-to-JS approach—but it wasn't built into the Turbo MVC tests.
3. Fast Failure Is Valuable
The spike took ~3 days from concept to "production-ready" code. But discovering the template duplication problem before committing to the approach saved weeks of maintenance pain.
AI-assisted development enabled fast exploration (hours to days, not weeks to months), which enabled fast failure identification.
4. Architectural Complexity vs. Essential Complexity
The Turbo MVC spike succeeded in reducing architectural complexity:
- No Shadow DOM
- No component lifecycle
- Simpler data layer
- Familiar Rails patterns
But it didn't address essential complexity: maintaining two template systems. The Web Components approach had the same problem—it just disguised it with architectural overhead.
The ERB-to-JS conversion approach finally addressed essential complexity by eliminating template duplication entirely.
Conclusion
The Turbo MVC spike was a productive dead end.
It proved offline capability didn't require Web Components, Service Workers could work with simple patterns, and template strings sufficed for rendering. The code was clean, well-tested, and architecturally sound.
But it revealed the real problem: template duplication, not architectural complexity.
The spike delivered exactly what exploration should:
- Validated a hypothesis (Turbo patterns work offline)
- Identified the real bottleneck (template maintenance)
- Pointed toward the solution (automatic conversion)
- Fast enough to pivot (3 days, not 3 weeks)
Sometimes the most valuable code is the code you don't ship—because it clarifies what actually needs solving.
Building resilient applications isn't just about choosing simpler architectures—it's about identifying and eliminating essential complexity.
The Turbo MVC spike showed that offline scoring didn't need complex component frameworks. The ERB-to-JS conversion showed it didn't need template duplication either.
Both insights were necessary. The spike enabled the breakthrough.
Complete spike implementation: github.com/rubys/showcase on branch turbo-mvc-spike. See commits 670f13e through 0a5e2e2 for the full development progression. The ERB-to-JavaScript conversion approach is on main branch starting from commit 188f97d.