Loading…

Building a Unified Timeline with Rails Delegated Types and Turbo Broadcasting

Jibran Kalia 12 min read
Written

بِسْمِ ٱللَّٰهِ ٱلرَّحْمَٰنِ ٱلرَّحِيمِ

A teacher's dashboard needs to show assignments, notes, and grades in a single timeline — but these are fundamentally different models. In this post, I'll walk through how we unified them using Rails' delegated_type, built a real-time broadcasting system with Turbo Streams, and kept it all under 210 lines of core code.

The Problem

QuranPortal has two types of content that appear on the teacher dashboard:

  • Assignments — with due dates, grades, verse ranges, mistake counts, and assignment types
  • Student Notes — with text content, dates, and visibility (public or internal)

Both need to appear in the same spreadsheet cell (organized by student and date), the same timeline view, and the same notification tray. We needed a single queryable model that could represent both without sacrificing type-specific behavior.

Why Not STI?

Single Table Inheritance would force both types into one table with many nullable columns. Assignments have 15+ columns (quality_rating, completed_at, mistake_count, hesitation_count, etc.) that make no sense for notes, and vice versa. The table would be sparse and confusing.

Delegated Types

Rails 6.1 introduced delegated_type — a pattern where a shared table stores common attributes and delegates type-specific behavior to separate tables via polymorphic association. Think of it as composition instead of inheritance.

The Entry Model: 16 Lines

# app/models/entry.rb

class Entry < ApplicationRecord
  include Broadcastable
  include Queryable

  acts_as_tenant :account

  belongs_to :personal_mushaf
  belongs_to :creator, class_name: "User"

  delegated_type :entryable, types: %w[Assignment StudentNote], dependent: :destroy

  enum :visibility, { public: "public", internal: "internal" }, prefix: true

  validates :entry_at, presence: true
  validates :visibility, presence: true
end

The delegated_type :entryable declaration gives us:

  • entry.entryable — returns the Assignment or StudentNote
  • entry.assignment? / entry.student_note? — type predicates
  • entry.assignment / entry.student_note — type-specific accessors (returns nil if wrong type)
  • Automatic dependent: :destroy — deleting the entry deletes its entryable

The Schema

CREATE TABLE entries (
  id                bigint PRIMARY KEY,
  account_id        bigint NOT NULL,
  personal_mushaf_id bigint NOT NULL,
  creator_id        bigint NOT NULL,
  entryable_type    varchar NOT NULL,  -- "Assignment" or "StudentNote"
  entryable_id      bigint NOT NULL,
  entry_at          timestamp NOT NULL,
  visibility        varchar NOT NULL,  -- "public" or "internal"
  created_at        timestamp NOT NULL,
  updated_at        timestamp NOT NULL
);

-- Key indexes
CREATE UNIQUE INDEX ON entries (entryable_type, entryable_id);
CREATE INDEX ON entries (personal_mushaf_id, entry_at, entryable_type);

The composite index on (personal_mushaf_id, entry_at, entryable_type) supports the primary query pattern: "all entries for this student on this date."

The Entryable Contract

Each entryable type must implement five methods. We enforce this with a concern that uses the template method pattern:

# app/models/concerns/entryable.rb

module Entryable
  extend ActiveSupport::Concern

  included do
    has_one :entry, as: :entryable, dependent: :destroy, touch: true
    after_create :create_entry!
  end

  private

  def create_entry!
    Entry.create!(
      account: derive_account,
      personal_mushaf: derive_personal_mushaf,
      creator: derive_creator,
      entryable: self,
      entry_at: derive_entry_at,
      visibility: derive_visibility
    )
  end

  def derive_account     = raise NotImplementedError
  def derive_personal_mushaf = raise NotImplementedError
  def derive_creator     = raise NotImplementedError
  def derive_entry_at    = raise NotImplementedError
  def derive_visibility  = raise NotImplementedError
end

When an Assignment or StudentNote is created, the after_create hook automatically creates the Entry record. The five derive_* methods let each type provide its own logic.

Assignment Implementation

# app/models/assignment.rb

include Entryable

def derive_account       = personal_mushaf.account
def derive_personal_mushaf = personal_mushaf
def derive_creator       = teacher
def derive_entry_at      = completed_at || due_date || created_at
def derive_visibility    = :public

The entry_at logic is key: a graded assignment appears on its completion date, a pending one on its due date. This ensures the spreadsheet cell shows entries on the date that matters to the teacher.

StudentNote Implementation

# app/models/student_note.rb

include Entryable

def derive_account       = student.account
def derive_personal_mushaf = student.personal_mushaf
def derive_creator       = teacher
def derive_entry_at      = date&.in_time_zone || created_at
def derive_visibility    = internal? ? :internal : :public

Notes respect their visibility flag — internal notes are hidden from parents.

Date-Based Querying

# app/models/entry/queryable.rb

module Entry::Queryable
  extend ActiveSupport::Concern

  included do
    scope :for_date, ->(date) {
      where(entry_at: date.in_time_zone.all_day)
    }

    scope :for_date_range, ->(start_date, end_date) {
      where(entry_at: start_date.in_time_zone.beginning_of_day..
                      end_date.in_time_zone.end_of_day)
    }

    scope :assignments,        -> { where(entryable_type: "Assignment") }
    scope :notes,              -> { where(entryable_type: "StudentNote") }
    scope :visible_to_parents, -> { visibility_public }
    scope :chronological,      -> { order(entry_at: :asc) }

    scope :completed, -> {
      assignments
        .joins("INNER JOIN assignments ON assignments.id = entries.entryable_id")
        .where.not(assignments: { completed_at: nil })
    }

    scope :pending, -> {
      assignments
        .joins("INNER JOIN assignments ON assignments.id = entries.entryable_id")
        .where(assignments: { completed_at: nil })
    }
  end
end

The .all_day call handles timezone-aware date boundaries. The completed and pending scopes join through to the assignments table — this is the one place where the delegated type abstraction gets "leaky," but it's pragmatic and fast.

Real-Time Broadcasting

This is where the system gets interesting. When a teacher grades an assignment or adds a note, every other teacher watching that classroom should see the update immediately. The Broadcastable module handles this in 154 lines.

Change Detection

# app/models/entry/broadcastable.rb

SIGNIFICANT_ASSIGNMENT_ATTRIBUTES = %w[quality_rating completed_at].freeze

included do
  before_update :remember_significant_changes
  before_update :remember_affected_dates

  after_create_commit  :broadcast_create
  after_update_commit  :broadcast_update, if: :should_broadcast?
  after_destroy_commit :broadcast_destroy
end

Not every change deserves a broadcast. We track what specifically changed:

def remember_significant_changes
  @specific_change = nil

  return unless assignment?

  if @entry_at_changed && assignment.completed_at.present? &&
     entry_at.to_date == assignment.completed_at.to_date
    @specific_change = :graded
  elsif assignment.quality_rating_changed?
    @specific_change = :graded
  elsif @entry_at_changed
    @specific_change = :due_date_moved
  elsif assignment.completed_at_changed?
    @specific_change = :completed
  elsif assignment.assignment_range&.changed?
    @specific_change = :range_changed
  end
end

The @specific_change drives the notification text: "Graded" vs. "Due date moved" vs. "Range updated". This contextual detail helps teachers understand what happened at a glance.

Affected Dates

When a due date moves from Thursday to Saturday, both cells need updating:

def remember_affected_dates
  @affected_dates = [entry_at.to_date]

  if entry_at_changed?
    @affected_dates << entry_at_was.to_date if entry_at_was
    @affected_dates.uniq!
  end
end

Broadcasting to Classroom Teachers

def broadcast_to_classroom_teachers(action)
  return if entryable.respond_to?(:backfilled?) && entryable.backfilled?

  student = personal_mushaf.student
  classroom = student.classroom
  dates = @affected_dates || [entry_at.to_date]

  classroom.teachers.find_each do |teacher|
    next if teacher == Current.user  # Don't notify yourself

    dates.each do |date|
      broadcast_cell_to_teacher(teacher, student, date, action)
    end
    broadcast_student_badges_to_teacher(teacher, student)
  rescue => e
    Rails.logger.error "Broadcast failed for teacher #{teacher.id}: #{e.message}"
  end
end

Key design decisions:

  • Skip backfilled entries — bulk imports shouldn't spam teachers with hundreds of notifications
  • Exclude the acting user — the teacher who made the change doesn't need a notification about it
  • Error isolation — if broadcasting to one teacher fails, continue to the next
  • find_each — batch loading prevents memory issues in large classrooms

Three Broadcast Channels

Each teacher subscribes to three independent channels. This separation allows the dashboard to update different UI sections independently.

1. Entry Updates (Spreadsheet Cells)

def broadcast_cell_to_teacher(teacher, student, date, action)
  entries = student.personal_mushaf.entries.for_date(date)
  cell_id = "entry_cell_#{student.id}_#{date.strftime('%Y%m%d')}"

  Turbo::StreamsChannel.broadcast_update_to(
    [teacher, :entry_updates],
    target: cell_id,
    partial: "dashboard/teacher/entry_cell_content",
    locals: { student: student, date: date, entries: entries }
  )

  broadcast_tray_notification_to(teacher, student, date, action)
end

The cell ID convention entry_cell_{student_id}_{YYYYMMDD} enables precise targeting. The entire cell is re-rendered with all entries for that date — not just the changed entry. This avoids ordering bugs and ensures consistency.

2. Notification Tray

def broadcast_tray_notification_to(teacher, student, date, action)
  Turbo::StreamsChannel.broadcast_prepend_to(
    [teacher, :entry_tray],
    target: "entry-tray-list",
    partial: "dashboard/teacher/notifications/entry_notification",
    locals: {
      student: student, entry: self, action: action,
      action_text: notification_action_text(action),
      actor_name: Current.user&.name_with_title,
      timestamp: Time.current
    }
  )
end

Notifications are prepended (newest first). The frontend groups multiple notifications from the same student using a Stimulus controller.

3. Timeline Badges

def broadcast_student_badges_to_teacher(teacher, student)
  Turbo::StreamsChannel.broadcast_update_to(
    [teacher, :timeline_updates],
    target: "student_badges_#{student.id}",
    partial: "dashboard/teacher/timeline/student_badges",
    locals: { student: student }
  )
end

Subscribing in Views

<%%= turbo_stream_from current_user, :entry_updates %>
<%%= turbo_stream_from current_user, :entry_tray %>
<%%= turbo_stream_from current_user, :timeline_updates %>

Channels are scoped to the individual teacher user, so broadcasts to one teacher never leak to another.

The CellResponder Pattern

Multiple controllers need to update spreadsheet cells after modifying entries. We extract this into a concern:

# app/controllers/concerns/cell_responder.rb

module CellResponder
  extend ActiveSupport::Concern

  private

  def render_cell_replacement(student, date)
    cell_id = entry_cell_id(student, date)
    entries = student.personal_mushaf.entries.for_date(date)

    turbo_stream.update(
      cell_id,
      partial: "dashboard/teacher/entry_cell_content",
      locals: { student: student, date: date, entries: entries }
    )
  end

  def respond_with_multi_cell_update(student, dates, additional_streams: [])
    streams = dates.uniq.map { |date| render_cell_replacement(student, date) }
    streams.concat(additional_streams)

    respond_to do |format|
      format.turbo_stream { render turbo_stream: streams }
      format.html { redirect_to dashboard_teacher_path, status: :see_other }
    end
  end
end

When a due date moves, the controller calls respond_with_multi_cell_update(student, [old_date, new_date]) to refresh both cells.

Notification Grouping

The notification tray uses a Stimulus controller to group multiple updates from the same student:

// app/javascript/controllers/entry_tray_controller.js

group() {
  const byStudent = this.#groupNotificationsByStudentId();

  for (const studentId in byStudent) {
    const notifications = byStudent[studentId];
    if (notifications.length > 1) {
      // Sort by timestamp, show newest, hide the rest
      notifications.sort((a, b) =>
        parseInt(b.dataset.timestamp) - parseInt(a.dataset.timestamp)
      );
      this.#showAsGrouped(notifications[0], notifications.length);
      notifications.slice(1).forEach(n => this.#hideInGroup(n));
    }
  }
}

The most recent notification for each student shows with a count badge ("3 updates"). Clicking it navigates to the relevant spreadsheet cell or timeline entry.

Performance

ETag-Based Caching

# Dashboard controller
fresh_when etag: [
  "teacher_spreadsheet",
  students_scope.cache_key_with_version,
  visible_entries_scope.cache_key_with_version
]

The ETag includes the combined cache key of all visible entries. When any entry changes, the cache key changes, and the browser fetches fresh content. When nothing changed, it gets a 304 Not Modified.

Entry Preloading

def preload_entries(students)
  visible_range = @visible_start_date.beginning_of_day..@visible_end_date.end_of_day
  mushafs = students.map(&:personal_mushaf)

  ActiveRecord::Associations::Preloader.new(
    records: mushafs,
    associations: :entries,
    scope: Entry.where(entry_at: visible_range).with_entryable_for_list
  ).call
end

A single batch query loads all entries for all visible students across the date range. No N+1 queries.

The Complete Flow

Here's what happens when Teacher A grades a student's assignment:

  1. Teacher A submits the grading form
  2. Assignment updatescompleted_at and quality_rating change
  3. Entry callbacks fireentry_at moves to the completion date
  4. Change detection@specific_change = :graded, affected dates tracked
  5. Broadcast to Teacher B — cell update, tray notification ("Graded"), badge refresh
  6. Teacher B sees the graded assignment appear in their spreadsheet cell and a notification in their tray — all without refreshing the page

The core of this system is 16 lines for the Entry model, 42 for the Entryable concern, 36 for Queryable, and 154 for Broadcastable. Small, focused modules that compose into a powerful real-time timeline.

Questions? Email [email protected].