Building a Unified Timeline with Rails Delegated Types and Turbo Broadcasting
بِسْمِ ٱللَّٰهِ ٱلرَّحْمَٰنِ ٱلرَّحِيمِ
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 StudentNoteentry.assignment?/entry.student_note?— type predicatesentry.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:
- Teacher A submits the grading form
- Assignment updates —
completed_atandquality_ratingchange - Entry callbacks fire —
entry_atmoves to the completion date - Change detection —
@specific_change = :graded, affected dates tracked - Broadcast to Teacher B — cell update, tray notification ("Graded"), badge refresh
- 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].