intertwingly

It’s just data

From ERB to JavaScript - Server Computes, Hydration Joins, Templates Filter


This post documents the third iteration of offline-first scoring for the showcase application—and the architecture that finally feels right. Previous posts covered Web Components (4,025 lines), Turbo MVC (2,604 lines, incomplete), and automatic ERB-to-JavaScript conversion (1,933 lines). This post describes how these pieces fit together into a coherent offline-first architecture.

The core insight: Server computes, hydration joins, templates filter.


The Problem Recap

During live dance competitions, hotel WiFi is notoriously unreliable. Judges score performances on tablets, and network outages during scoring are mission-critical failures. The solution: a Single Page Application that works offline while keeping Rails as the source of truth.

Previous iterations taught important lessons:

  1. Web Components (4,025 lines): Proved offline capability works, but Shadow DOM and component lifecycles added unfamiliar complexity
  2. Turbo MVC (2,604 lines, incomplete): Simpler architecture, but required maintaining parallel JavaScript templates alongside ERB
  3. ERB-to-JS conversion (1,933 lines): Eliminated template duplication by automatically converting ERB to JavaScript

The final architecture combines automatic template conversion with a clean data pipeline.


Architecture: Server Computes, Hydration Joins, Templates Filter

This principle guides the entire implementation:

1. Server Computes

Rails extracts all data a judge needs for an event, normalized like a relational database. Instead of sending nested objects for each heat:

{
  "heat": {
    "number": 5,
    "dance": { "name": "Waltz", "category": "Smooth" },
    "entry": {
      "lead": { "name": "John Smith", "back": 101, "studio": { "name": "Fred Astaire" } },
      "follow": { "name": "Jane Doe", "back": 102, "studio": { "name": "Fred Astaire" } }
    }
  }
}

The server sends lookup tables:

{
  "heats": [{ "id": 5, "number": 5, "dance_id": 12, "entry_id": 42 }],
  "dances": { "12": { "name": "Waltz", "category": "Smooth" } },
  "entries": { "42": { "lead_id": 101, "follow_id": 102 } },
  "people": { "101": { "name": "John Smith", "back": 101, "studio_id": 7 },
              "102": { "name": "Jane Doe", "back": 102, "studio_id": 7 } },
  "studios": { "7": { "name": "Fred Astaire" } }
}

Why? A person appearing in 50 heats is transmitted once, not 50 times. For a mid-sized event, this compresses ~13MB of nested data to ~176KB of normalized data.

The server also computes derived values. For example, dance_string (a formatted display string combining dance name, level, and category) is computed server-side because it requires complex business logic. These computed values travel with the normalized data.

2. Hydration Joins

On the client, buildLookupTables() and hydrateHeat() in heat_hydrator.js reconstruct nested objects by resolving IDs:

export function buildLookupTables(rawData) {
  return {
    people: new Map(Object.entries(rawData.people || {})),
    studios: new Map(Object.entries(rawData.studios || {})),
    dances: new Map(Object.entries(rawData.dances || {})),
    entries: new Map(Object.entries(rawData.entries || {})),
    // ... etc
  };
}

export function hydrateHeat(heat, lookups) {
  // Convert IDs to full objects
  const dance = lookups.dances.get(String(heat.dance_id));
  const entry = lookups.entries.get(String(heat.entry_id));

  // Hydrate nested relationships
  entry.lead = lookups.people.get(String(entry.lead_id));
  entry.lead.studio = lookups.studios.get(String(entry.lead.studio_id));
  // ... etc

  return { ...heat, dance, entry };
}

The result is a data structure that can be traversed like ActiveRecord collections—heat.entry.lead.studio.name works just like in Ruby.

3. Templates Filter

JavaScript templates (automatically converted from ERB) receive hydrated data and render HTML. They don't compute—they just filter and format:

// Generated from ERB: <%= subject.entry.lead.studio.name %>
html += (subject.entry.lead.studio.name) || '';

// Generated from ERB: <% @subjects.each do |subject| %>
data.subjects.forEach(subject => {
  // render subject row
});

The templates are identical to their ERB counterparts because they're automatically converted, not hand-written.


ERB-to-JavaScript Conversion

Ruby 3.4's Prism parser enables automatic conversion. ERB templates contain Ruby expressions—loops, conditionals, method calls—that Prism parses into an Abstract Syntax Tree. The converter walks this AST and generates equivalent JavaScript.

Rails endpoint (/templates/scoring.js) dynamically converts ERB templates:

class TemplatesController < ApplicationController
  def scoring
    templates = %w[heat heatlist _heat_header _info_box _navigation_footer
                   _cards_heat _rank_heat _solo_heat _table_heat]

    converter = ErbPrismConverter.new
    js_modules = templates.map do |name|
      erb_path = Rails.root.join("app/views/scores/#{name}.html.erb")
      erb_content = File.read(erb_path)
      function_name = name.delete_prefix('_').camelize(:lower)
      converter.convert(erb_content, function_name: function_name)
    end

    render js: js_modules.join("\n\n"), content_type: 'text/javascript'
  end
end

Translation examples:

# Instance variables → data object
@subjects          →  data.subjects
@event.open_scoring →  data.event.open_scoring

# Blocks → arrow functions
<% @subjects.each do |subject| %>  →  data.subjects.forEach(subject => {
  # ...                                // ...
<% end %>                            });

# Rails helpers → JavaScript equivalents
dom_id(heat)       →  domId(heat)
link_to(...)       →  `<a href="..." data-turbo-frame="...">`

The converter handles nested conditionals, string interpolation, safe navigation (&.?.), and Rails path helpers.


Data Flow: Online and Offline

Initial Load

  1. Stimulus controller connects (heat_app_controller.js)
  2. Load templates once: fetch('/templates/scoring.js') → cached ES modules
  3. Load all data once: fetch('/scores/:judge/heats/data') → 176KB normalized JSON
  4. Build lookup tables: Convert arrays to Maps for O(1) access
  5. Render current heat: Hydrate data, call template function, update DOM

Heat Navigation (Online)

  1. User clicks next/prev heat
  2. Version check: fetch('/scores/:judge/version/:heat') returns lightweight metadata:
    { "max_updated_at": "2025-11-24T15:30:00Z", "heat_count": 142 }
  3. Compare versions: If unchanged, use cached data (no fetch)
  4. If changed: Fetch fresh data, update cache
  5. Render: Hydrate heat from in-memory data, call template, update DOM

Heat Navigation (Offline)

  1. Version check fails (network error)
  2. Fall back to cached data: Already in memory
  3. Continue navigation: All data was loaded initially
  4. Queue score updates: Store in IndexedDB for later upload

Score Entry

Online:

  1. User enters score
  2. POST to /scores/:judge/post
  3. Update in-memory data
  4. Update IndexedDB cache

Offline:

  1. POST fails (network unavailable)
  2. Queue in IndexedDB: DirtyScoresQueue stores score with timestamp
  3. Update in-memory data (optimistic)
  4. Show offline indicator

Reconnection

  1. Detect connectivity restored (version check succeeds on navigation, or browser online event)
  2. Batch upload: POST /scores/:judge/batch with all queued scores
  3. Clear dirty queue on success
  4. Fetch fresh data if stale (compare max_updated_at from version check)

Service Worker: Simple Cache

The service worker uses network-first strategy:

self.addEventListener('fetch', (event) => {
  event.respondWith(
    fetch(event.request)
      .then(response => {
        // Cache successful GET responses
        if (response.ok) {
          const cache = await caches.open('showcase-v1');
          cache.put(event.request, response.clone());
        }
        return response;
      })
      .catch(() => caches.match(event.request))  // Fallback to cache
  );
});

This ensures:


Debugging: render_erb_and_js.rb

This script compares ERB and JavaScript rendering side-by-side:

$ RAILS_APP_DB=2025-barcelona-november ruby scripts/render_erb_and_js.rb 83 136

Rendering ERB version...
✓ ERB rendered: 12,847 bytes, 8 <tr> tags
  Saved to: /tmp/erb_rendered.html

Rendering JavaScript version...
✓ Templates fetched: 38,912 bytes
✓ Heat 136 hydrated with 1 subjects
  Saved template data to: /tmp/js_template_data.json
✓ JS rendered: 12,847 bytes, 8 <tr> tags
  Saved to: /tmp/js_rendered.html

Files saved to /tmp/ for analysis:
  /tmp/erb_rendered.html       - ERB template output
  /tmp/js_rendered.html        - JavaScript template output
  /tmp/scoring_templates.js    - Converted templates
  /tmp/js_template_data.json   - Complete template data

When outputs differ, you can:

  1. diff /tmp/erb_rendered.html /tmp/js_rendered.html to see differences
  2. Inspect /tmp/js_template_data.json to verify data correctness
  3. Examine /tmp/scoring_templates.js to debug conversion issues

This compresses hours of debugging into minutes.


Code Comparison

Final Implementation

Infrastructure:

Auto-generated (not counted):

vs. Previous Approaches

Approach Hand-written Code Notes
Web Components 4,025 lines Shadow DOM, component lifecycle
Turbo MVC 2,604 lines Template duplication, incomplete
ERB-to-JS 1,933 lines Single source of truth

Result: 52% less code than Web Components, with guaranteed template parity.


What This Enables

For Judges:

For Developers:

For Operations:


Remaining Work

The architecture is complete. What remains:

  1. UI polish: Offline indicator, pending scores badge
  2. Error recovery: Handle partial batch upload failures
  3. Production validation: Test at actual live events

See the implementation plan for detailed steps.


Lessons Learned

Fast Iteration Enables Multiple Rewrites

Three complete implementations in three weeks. Without Claude Code compressing exploration time, I wouldn't have attempted the second rewrite—let alone the third. Each iteration revealed insights that led to a better solution.

Invert the Problem

Both Web Components and Turbo MVC required maintaining parallel templates. The breakthrough came from asking: "What if ERB were the source of truth and JavaScript were generated?" This eliminated duplication entirely.

Architecture Principles Guide Implementation

"Server computes, hydration joins, templates filter" provides clarity at every decision point. Should this logic be in the controller or template? Check the principle. Should this computation happen server-side or client-side? Check the principle.

Build Debugging Tools Early

render_erb_and_js.rb emerged organically but became essential. When template behavior differs, having intermediate artifacts (normalized data, hydrated data, generated JavaScript) makes debugging straightforward.


Try It Yourself

All components are open source:


Conclusion

Server computes, hydration joins, templates filter.

This architecture delivers offline-first scoring with:

The journey through three implementations wasn't wasted effort—it was essential exploration that compressed the solution space. Each "failed" approach revealed what actually needed solving.

Building resilient event applications isn't about choosing simpler architectures—it's about finding the right abstraction that eliminates essential complexity.

For the architectural context of showcase's multi-tenant deployment, see Shared-Nothing Multi-Tenancy. For the development methodology, see Disciplined use of Claude.


For complete implementation details including converter tests, hydration logic, and offline queue management, see the showcase repository.