Focus Timer Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the /focus page (stats + sessions + heatmap + patterns), the start-timer modal, the active-session widget that survives page reloads, the polymorphic linking to TodoCard / Habit / Goal, the XP integration, and CSV export.

Architecture: A FocusSession model with polymorphic focusable, an enum-driven state machine on the model (running → paused → completed stopped), six small namespaced calculators (Snapshot, Heatmap, Stats, TimeDistribution, Patterns, Exporter), two thin controllers (FocusController#show, FocusSessionsController for CRUD + transitions), and a Stimulus countdown widget that reconciles client state against the server every 30s.

Tech Stack: Rails 8.1, Pundit, Pagy, solid_cache, ActionCable (optional), Stimulus, Tailwind CSS v4, Minitest fixtures, ActionController::Live for streaming CSV.

Cross-cutting principles enforced in every task:

  • DRY: every stats panel is its own namespaced calculator under app/models/focus_session/. The Snapshot facade is the only thing the controller talks to.
  • Performance: (user_id, started_at) index covers all time-bounded queries. Heatmap cached per user for 5 minutes. CSV streams in batches of 500. Active widget polls only when running.
  • Security: polymorphic focusable_type allowlist, cross-user focusable_id rejection, server-side recomputation of duration_seconds (clients can't farm XP), enum-only state writes via explicit transition methods.

File Structure

  • Create: db/migrate/<ts>_create_focus_sessions.rb
  • Create: db/migrate/<ts>_add_focus_xp_to_gamification_profiles.rb
  • Create: app/models/focus_session.rb
  • Create: app/models/focus_session/snapshot.rb
  • Create: app/models/focus_session/heatmap.rb
  • Create: app/models/focus_session/stats.rb
  • Create: app/models/focus_session/time_distribution.rb
  • Create: app/models/focus_session/patterns.rb
  • Create: app/models/focus_session/exporter.rb
  • Modify: app/models/user.rb (add has_many :focus_sessions, dependent: :destroy)
  • Modify: app/models/gamification/xp_awarder.rb (add award_focus_completion)
  • Modify: app/models/gamification_profile.rb (focus_xp accessor)
  • Modify: app/models/todo_card.rb / habit.rb / goal.rb (has_many :focus_sessions, as: :focusable)
  • Create: app/controllers/focus_controller.rb
  • Create: app/controllers/focus_sessions_controller.rb
  • Create: app/policies/focus_policy.rb
  • Create: app/policies/focus_session_policy.rb
  • Create: app/views/focus/show.html.erb
  • Create: app/views/focus/_hero.html.erb
  • Create: app/views/focus/_sessions_list.html.erb
  • Create: app/views/focus/_session_row.html.erb
  • Create: app/views/focus/_stat_cards.html.erb
  • Create: app/views/focus/_time_distribution.html.erb
  • Create: app/views/focus/_session_stats.html.erb
  • Create: app/views/focus/_focus_patterns.html.erb
  • Create: app/views/focus/_export.html.erb
  • Create: app/views/focus/_active_widget.html.erb
  • Create: app/views/focus/_start_modal.html.erb
  • Create: app/views/focus_sessions/edit.html.erb
  • Create: app/views/layouts/_active_focus_widget.html.erb (rendered globally)
  • Modify: app/views/layouts/application.html.erb (mount widget)
  • Create: app/javascript/controllers/focus_timer_controller.js
  • Create: app/javascript/controllers/focus_start_modal_controller.js
  • Create: app/javascript/controllers/focus_heatmap_tooltip_controller.js
  • Modify: config/routes.rb
  • Modify: config/initializers/rack_attack.rb
  • Modify: app/views/shared/_sidebar.html.erb
  • Modify: app/views/shared/_navbar.html.erb
  • Modify: app/views/shared/_footer_nav.html.erb
  • Modify: app/helpers/application_helper.rb
  • Modify: config/locales/en.yml
  • Modify: config/locales/pt-BR.yml
  • Create: test/fixtures/focus_sessions.yml
  • Create: test/controllers/focus_controller_test.rb
  • Create: test/controllers/focus_sessions_controller_test.rb
  • Create: test/models/focus_session_test.rb
  • Create: test/models/focus_session/heatmap_test.rb
  • Create: test/models/focus_session/stats_test.rb
  • Create: test/models/focus_session/time_distribution_test.rb
  • Create: test/models/focus_session/patterns_test.rb
  • Create: test/models/focus_session/exporter_test.rb

Task 1: Migrations

Files:

  • Create: db/migrate/<ts>_create_focus_sessions.rb
  • Create: db/migrate/<ts>_add_focus_xp_to_gamification_profiles.rb

  • Step 1: Generate
bin/rails g migration create_focus_sessions
bin/rails g migration add_focus_xp_to_gamification_profiles focus_xp:integer
  • Step 2: Edit create_focus_sessions
class CreateFocusSessions < ActiveRecord::Migration[8.1]
  def change
    create_table :focus_sessions do |t|
      t.references :user, null: false, foreign_key: true
      t.string  :focusable_type
      t.bigint  :focusable_id
      t.string  :mode,             null: false
      t.string  :status,           null: false, default: "running"
      t.datetime :started_at,      null: false
      t.datetime :ended_at
      t.integer  :planned_seconds,  null: false
      t.integer  :duration_seconds, null: false, default: 0
      t.integer  :paused_seconds,   null: false, default: 0
      t.datetime :paused_at
      t.text     :note
      t.integer  :xp_awarded,       null: false, default: 0
      t.timestamps
    end

    add_index :focus_sessions, %i[user_id started_at]
    add_index :focus_sessions, %i[focusable_type focusable_id]
    add_index :focus_sessions, %i[user_id status]
  end
end
  • Step 3: Edit add_focus_xp_to_gamification_profiles
class AddFocusXpToGamificationProfiles < ActiveRecord::Migration[8.1]
  def change
    add_column :gamification_profiles, :focus_xp, :integer, default: 0, null: false
  end
end
  • Step 4: Run + commit
bin/rails db:migrate
git add db/migrate db/schema.rb
git commit -m "feat: focus_sessions table and focus_xp column"

Task 2: FocusSession Model

Files:

  • Create: app/models/focus_session.rb
  • Modify: app/models/user.rb
  • Modify: app/models/todo_card.rb, app/models/habit.rb, app/models/goal.rb
  • Create: test/fixtures/focus_sessions.yml
  • Create: test/models/focus_session_test.rb

  • Step 1: Failing model tests
require "test_helper"

class FocusSessionTest < ActiveSupport::TestCase
  test "valid free session" do
    session = build_session
    assert session.valid?
  end

  test "rejects unknown mode" do
    session = build_session(mode: "totally_made_up")
    refute session.valid?
  end

  test "rejects planned_seconds over 8 hours" do
    session = build_session(planned_seconds: 9.hours.to_i)
    refute session.valid?
  end

  test "polymorphic focusable accepts TodoCard, Habit, Goal" do
    %w[TodoCard Habit Goal].each do |klass|
      assert FocusSession::ALLOWED_FOCUSABLES.include?(klass)
    end
  end

  test "complete! sets status, ended_at, and recomputes duration" do
    session = build_session(status: :running, started_at: 25.minutes.ago, planned_seconds: 25.minutes.to_i, duration_seconds: 0)
    session.save!
    travel_to 25.minutes.from_now do
      session.complete!
    end
    assert_equal "completed", session.status
    assert session.ended_at.present?
    assert_in_delta 25.minutes.to_i, session.duration_seconds, 5
  end

  test "complete! is idempotent" do
    session = build_session(status: :completed, ended_at: 1.minute.ago, duration_seconds: 25.minutes.to_i)
    session.save!
    assert_no_changes -> { session.reload.attributes } do
      session.complete!
    end
  end

  test "stop! cannot be called on a completed session" do
    session = build_session(status: :completed, ended_at: 1.minute.ago)
    session.save!
    assert_raises(FocusSession::InvalidTransition) { session.stop! }
  end

  private

  def build_session(**attrs)
    FocusSession.new({
      user: users(:admin),
      mode: "pomodoro_25",
      status: "running",
      started_at: Time.current,
      planned_seconds: 25.minutes.to_i,
      duration_seconds: 0
    }.merge(attrs))
  end
end
  • Step 2: Implement model
class FocusSession < ApplicationRecord
  class InvalidTransition < StandardError; end
  class InvalidFocusable  < StandardError; end

  ALLOWED_FOCUSABLES = %w[TodoCard Habit Goal].freeze
  MAX_PLANNED_SECONDS = 8.hours.to_i
  XP_PER_SESSION_CAP  = 15

  belongs_to :user
  belongs_to :focusable, polymorphic: true, optional: true

  enum :mode, {
    free:        "free",
    pomodoro_25: "pomodoro_25",
    pomodoro_50: "pomodoro_50",
    quick_15:    "quick_15",
    deep_90:     "deep_90",
    custom:      "custom"
  }, validate: true

  enum :status, {
    running:   "running",
    paused:    "paused",
    completed: "completed",
    stopped:   "stopped"
  }, validate: true

  validates :planned_seconds,  numericality: { greater_than: 0, less_than_or_equal_to: MAX_PLANNED_SECONDS }
  validates :duration_seconds, numericality: { greater_than_or_equal_to: 0 }
  validate  :focusable_is_allowed

  scope :completed_sessions, -> { where(status: :completed) }
  scope :for_month,          ->(date) { where(started_at: date.beginning_of_month..date.end_of_month) }
  scope :for_user_recent,    ->(user, limit: 20) { where(user:).order(started_at: :desc).limit(limit) }

  def pause!
    raise InvalidTransition, "can only pause running" unless running?
    update!(status: :paused, paused_at: Time.current)
  end

  def resume!
    raise InvalidTransition, "can only resume paused" unless paused?
    elapsed = (Time.current - paused_at).to_i
    update!(status: :running, paused_at: nil, paused_seconds: paused_seconds + elapsed)
  end

  def complete!
    return if completed?
    raise InvalidTransition, "cannot complete from #{status}" unless running? || paused?
    finalize_paused
    duration = compute_duration
    update!(status: :completed, ended_at: Time.current, duration_seconds: duration)
    Gamification::XpAwarder.award_focus_completion(user, self)
  end

  def stop!
    raise InvalidTransition, "cannot stop from #{status}" unless running? || paused?
    finalize_paused
    update!(status: :stopped, ended_at: Time.current, duration_seconds: compute_duration)
  end

  private

  def finalize_paused
    return unless paused?
    elapsed = (Time.current - paused_at).to_i
    self.paused_seconds = paused_seconds + elapsed
    self.paused_at = nil
  end

  def compute_duration
    elapsed = (Time.current - started_at).to_i - paused_seconds
    elapsed.clamp(0, MAX_PLANNED_SECONDS)
  end

  def focusable_is_allowed
    return if focusable_type.blank? || ALLOWED_FOCUSABLES.include?(focusable_type)
    errors.add(:focusable_type, "is not an allowed type")
  end
end
  • Step 3: Update related models
# app/models/user.rb
has_many :focus_sessions, dependent: :destroy

# app/models/todo_card.rb / habit.rb / goal.rb
has_many :focus_sessions, as: :focusable, dependent: :nullify
  • Step 4: Fixtures
admin_completed_25:
  user: admin
  mode: pomodoro_25
  status: completed
  started_at: <%= 1.day.ago.iso8601 %>
  ended_at:   <%= (1.day.ago + 25.minutes).iso8601 %>
  planned_seconds: 1500
  duration_seconds: 1500
  paused_seconds: 0
  xp_awarded: 5

admin_running:
  user: admin
  mode: free
  status: running
  started_at: <%= 5.minutes.ago.iso8601 %>
  planned_seconds: 1500
  duration_seconds: 0
  paused_seconds: 0
  • Step 5: Tests pass + commit
bin/rails test test/models/focus_session_test.rb
git commit -am "feat: FocusSession model with explicit state transitions"

Task 3: XP Integration

Files:

  • Modify: app/models/gamification/xp_awarder.rb

  • Step 1: Failing test

In test/models/focus_session_test.rb, add:

test "completing a session awards XP capped at 15 and writes XpTransaction" do
  session = build_session(status: :running, started_at: 90.minutes.ago, planned_seconds: 90.minutes.to_i)
  session.save!

  assert_difference -> { XpTransaction.count }, 1 do
    session.complete!
  end

  txn = XpTransaction.last
  assert_equal session.user_id, txn.user_id
  assert_equal "focus", txn.category
  assert_equal session, txn.source
  assert_operator session.reload.xp_awarded, :<=, FocusSession::XP_PER_SESSION_CAP
  assert_operator session.user.gamification_profile.focus_xp, :>=, session.xp_awarded
end
  • Step 2: Add awarder method

In app/models/gamification/xp_awarder.rb:

def self.award_focus_completion(user, session)
  return if session.xp_awarded.positive?

  minutes = session.duration_seconds / 60
  amount  = focus_xp_for(minutes)
  return if amount.zero?

  ActiveRecord::Base.transaction do
    profile = user.gamification_profile || user.create_gamification_profile!
    profile.update!(focus_xp: profile.focus_xp + amount, total_xp: profile.total_xp + amount)
    XpTransaction.create!(
      user: user,
      amount: amount,
      category: "focus",
      description: I18n.t("focus.xp.completed_session", minutes: minutes),
      source: session
    )
    session.update_columns(xp_awarded: amount)
  end
end

def self.focus_xp_for(minutes)
  return 0 if minutes < 5
  award = 0
  award += [minutes, 25].min / 5             # 1 XP / 5 min, max 5
  award += ([minutes, 60].min - 25).clamp(0, 35) / 10  # 1 XP / 10 min for 25-60, max 4
  award += ([minutes, 8 * 60].min - 60).clamp(0, 7 * 60) / 30  # 1 XP / 30 min for 60+, capped
  [award, FocusSession::XP_PER_SESSION_CAP].min
end

Add to GamificationProfile model:

attribute :focus_xp, :integer, default: 0
  • Step 3: Tests pass + commit
bin/rails test test/models/focus_session_test.rb
git commit -am "feat: focus session XP awarding"

Task 4: Routes, Policy, Controller, Access Tests

Files:

  • Modify: config/routes.rb
  • Create: app/controllers/focus_controller.rb
  • Create: app/controllers/focus_sessions_controller.rb
  • Create: app/policies/focus_policy.rb
  • Create: app/policies/focus_session_policy.rb
  • Create: test/controllers/focus_controller_test.rb
  • Create: test/controllers/focus_sessions_controller_test.rb

  • Step 1: Failing access tests
require "test_helper"

class FocusControllerTest < ActionDispatch::IntegrationTest
  include Devise::Test::IntegrationHelpers
  setup { sign_in users(:admin) }

  test "GET /focus renders" do
    get focus_url
    assert_response :success
    assert_select "h1", I18n.t("focus.title")
  end

  test "unauthenticated redirects" do
    sign_out users(:admin)
    get focus_url
    assert_redirected_to new_user_session_path
  end
end

class FocusSessionsControllerTest < ActionDispatch::IntegrationTest
  include Devise::Test::IntegrationHelpers
  setup { sign_in users(:admin) }

  test "POST creates a free session" do
    assert_difference -> { FocusSession.count }, 1 do
      post focus_sessions_url, params: { focus_session: { mode: "pomodoro_25" } }
    end
    assert_response :redirect
  end

  test "POST rejects unknown focusable_type" do
    post focus_sessions_url, params: { focus_session: { mode: "free", focusable_type: "User", focusable_id: users(:admin).id } }
    assert_response :unprocessable_entity
  end

  test "POST rejects cross-user focusable_id" do
    other = users(:member_one)
    other_card = todo_cards(:member_one_card) # add to fixtures
    post focus_sessions_url, params: { focus_session: { mode: "free", focusable_type: "TodoCard", focusable_id: other_card.id } }
    assert_response :unprocessable_entity
  end

  test "PATCH transitions are authorized to owner" do
    session = focus_sessions(:admin_running)
    patch focus_session_url(session), params: { transition: "complete" }
    assert_equal "completed", session.reload.status
  end

  test "PATCH cross-user transition is forbidden" do
    sign_in users(:member_one)
    session = focus_sessions(:admin_running)
    patch focus_session_url(session), params: { transition: "complete" }
    assert_response :forbidden
  end
end
  • Step 2: Routes
authenticated :user do
  resource :focus, only: %i[show], controller: "focus"
  resources :focus_sessions, except: %i[show new] do
    collection { get :active }
    collection { get :export, defaults: { format: :csv } }
  end
end
  • Step 3: Policies
class FocusPolicy < ApplicationPolicy
  def show? = user.present?
end

class FocusSessionPolicy < ApplicationPolicy
  def create?  = true
  def update?  = record.user_id == user.id
  def destroy? = record.user_id == user.id

  class Scope < Scope
    def resolve = scope.where(user_id: user.id)
  end
end
  • Step 4: FocusController#show
class FocusController < ApplicationController
  def show
    authorize :focus
    @snapshot = FocusSession::Snapshot.new(current_user, status: params[:status], window: params[:window])
  end
end
  • Step 5: FocusSessionsController
class FocusSessionsController < ApplicationController
  before_action :set_session, only: %i[update destroy edit]

  def create
    authorize FocusSession
    session = build_session
    session.save!
    redirect_to focus_path
  rescue ActiveRecord::RecordInvalid => e
    render plain: e.message, status: :unprocessable_entity
  end

  def update
    authorize @session
    transition = params[:transition].to_s
    case transition
    when "pause"    then @session.pause!
    when "resume"   then @session.resume!
    when "complete" then @session.complete!
    when "stop"     then @session.stop!
    when "edit_note" then @session.update!(note: params.require(:focus_session).permit(:note)[:note])
    else head :unprocessable_entity and return
    end
    respond_to do |f|
      f.turbo_stream
      f.html { redirect_to focus_path }
    end
  rescue FocusSession::InvalidTransition
    head :unprocessable_entity
  end

  def destroy
    authorize @session
    @session.destroy!
    redirect_to focus_path
  end

  def active
    session = current_user.focus_sessions.where(status: %w[running paused]).order(started_at: :desc).first
    if session
      authorize session
      render partial: "focus/active_widget", locals: { session: session }
    else
      head :no_content
    end
  end

  def export
    authorize FocusSession, :create?
    response.headers["Content-Type"] = "text/csv"
    response.headers["Content-Disposition"] = "attachment; filename=focus-sessions-#{Date.current}.csv"
    self.response_body = FocusSession::Exporter.new(current_user, range: params[:range]).each
  end

  private

  def set_session
    @session = current_user.focus_sessions.find(params[:id])
  end

  def build_session
    attrs = create_params
    focusable = resolve_focusable!(attrs[:focusable_type], attrs[:focusable_id])
    current_user.focus_sessions.build(
      mode: attrs[:mode],
      status: :running,
      started_at: Time.current,
      planned_seconds: planned_seconds_for(attrs[:mode], attrs[:planned_seconds]),
      focusable: focusable
    )
  end

  def create_params
    params.require(:focus_session).permit(:mode, :planned_seconds, :focusable_type, :focusable_id)
  end

  def planned_seconds_for(mode, custom)
    presets = { "pomodoro_25" => 1500, "pomodoro_50" => 3000, "quick_15" => 900, "deep_90" => 5400 }
    return presets[mode] if presets.key?(mode)
    return 1500 if mode == "free"
    custom.to_i.clamp(60, FocusSession::MAX_PLANNED_SECONDS)
  end

  def resolve_focusable!(type, id)
    return nil if type.blank? || id.blank?
    raise ActiveRecord::RecordInvalid.new(FocusSession.new) unless FocusSession::ALLOWED_FOCUSABLES.include?(type)
    klass = type.safe_constantize
    raise ActiveRecord::RecordInvalid.new(FocusSession.new) unless klass

    case type
    when "TodoCard" then current_user.todo_cards.find(id)
    when "Habit"    then current_user.habits.find(id)
    when "Goal"     then current_user.goals.find(id)
    end
  rescue ActiveRecord::RecordNotFound
    raise ActiveRecord::RecordInvalid.new(FocusSession.new)
  end
end
  • Step 6: Minimal show.html.erb
<h1><%= t("focus.title") %></h1>
  • Step 7: Translations
en:
  focus:
    title: Timer
    subtitle: Track your focus time and productivity
    xp:
      completed_session: "Completed a %{minutes}-minute focus session"
    modes:
      free: Free
      pomodoro_25: Pomodoro 25m
      pomodoro_50: Pomodoro 50m
      quick_15: Quick 15m
      deep_90: Deep 90m
      custom: Custom

(Mirror in pt-BR.yml.)

  • Step 8: Tests pass + commit
bin/rails test test/controllers/focus_controller_test.rb test/controllers/focus_sessions_controller_test.rb
git commit -am "feat: focus controllers, routes, policies"

Task 5: Heatmap Calculator

Files:

  • Create: app/models/focus_session/heatmap.rb
  • Create: test/models/focus_session/heatmap_test.rb

  • Step 1: Failing test
require "test_helper"

class FocusSession::HeatmapTest < ActiveSupport::TestCase
  test "returns 365 keys covering the last year" do
    grid = FocusSession::Heatmap.new(users(:admin)).call
    assert_equal 365, grid.size
    assert grid.values.all? { |v| v.is_a?(Integer) }
  end

  test "missing days zero-fill" do
    grid = FocusSession::Heatmap.new(users(:admin)).call
    fixture_day = focus_sessions(:admin_completed_25).started_at.to_date
    refute_equal 0, grid[fixture_day]
    other = fixture_day - 200
    assert_equal 0, grid[other]
  end
end
  • Step 2: Implement
class FocusSession::Heatmap
  def initialize(user)
    @user = user
  end

  def call
    Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
      end_date   = Date.current
      start_date = end_date - 364

      sums = @user.focus_sessions
                  .completed_sessions
                  .where(started_at: start_date.beginning_of_day..end_date.end_of_day)
                  .group("date(started_at)")
                  .sum(:duration_seconds)

      (start_date..end_date).each_with_object({}) { |date, h| h[date] = sums[date.to_s].to_i / 60 }
    end
  end

  private

  def cache_key = ["focus", :heatmap, @user.id, @user.focus_sessions.maximum(:updated_at).to_i]
end
  • Step 3: Bust cache hook

In FocusSession:

after_commit :bust_user_caches, on: %i[create update destroy]

private

def bust_user_caches
  Rails.cache.delete_matched("focus/heatmap/#{user_id}/*")
end
  • Step 4: Tests pass + commit
bin/rails test test/models/focus_session/heatmap_test.rb
git commit -am "feat: focus heatmap calculator with cache"

Task 6: Stats, TimeDistribution, Patterns Calculators

Files:

  • Create: app/models/focus_session/stats.rb
  • Create: app/models/focus_session/time_distribution.rb
  • Create: app/models/focus_session/patterns.rb
  • Create: corresponding tests under test/models/focus_session/

Each calculator follows the same shape:

class FocusSession::Stats
  def initialize(user, now: Time.current)
    @user = user
    @now  = now
  end

  def streak_days        # consecutive days with at least one completed session
  def best_streak_days
  def this_week_minutes
  def last_week_minutes
  def week_over_week_pct
  def peak_time_bucket   # :morning | :afternoon | :evening | :night
  def best_day_of_week   # :sun..:sat
  def avg_session_minutes_for_best_day
end

Implement each as small, well-named SQL aggregations. Use grouping:

  • Streak: query distinct dates of completed sessions in the last 365 days, walk backwards from today.
  • Week minutes: where(started_at: window).sum(:duration_seconds) / 60.
  • Peak time: group("EXTRACT(HOUR FROM started_at)").sum(:duration_seconds) then bucket into windows.
  • Best day: group("EXTRACT(DOW FROM started_at)").sum(:duration_seconds).

For time_distribution.rb, group by focusable_type+focusable_id, sum duration, and resolve labels via a single preload(:focusable) per type:

class FocusSession::TimeDistribution
  Row = Struct.new(:label, :sessions, :minutes, :focusable, keyword_init: true)

  def initialize(user, kind: :tasks)
    @user = user
    @kind = kind
  end

  def call
    type = { tasks: "TodoCard", habits: "Habit", goals: "Goal", free: nil }.fetch(@kind)
    scope = @user.focus_sessions.completed_sessions
    scope = type.nil? ? scope.where(focusable_type: nil) : scope.where(focusable_type: type)

    rows = scope.group(:focusable_type, :focusable_id).pluck(
      :focusable_type, :focusable_id,
      Arel.sql("COUNT(*) AS sessions"),
      Arel.sql("SUM(duration_seconds) AS seconds")
    )

    focusables = preload_focusables(rows.map { |r| [r[0], r[1]] })

    rows.map do |type_, id_, sessions, seconds|
      f = focusables[[type_, id_]]
      Row.new(label: f&.send(label_method_for(type_)) || I18n.t("focus.distribution.free"),
              sessions: sessions, minutes: seconds / 60, focusable: f)
    end.sort_by { |r| -r.minutes }.first(10)
  end

  private

  def preload_focusables(pairs)
    by_type = pairs.group_by(&:first)
    {}.tap do |out|
      by_type.each do |type, type_pairs|
        next if type.nil?
        ids = type_pairs.map(&:last)
        klass = type.safe_constantize
        klass.where(id: ids).each { |obj| out[[type, obj.id]] = obj }
      end
    end
  end

  def label_method_for(type)
    case type
    when "TodoCard" then :title
    when "Habit"    then :name
    when "Goal"     then :name
    else :to_s
    end
  end
end

patterns.rb reuses the same hour/dow grouping queries to render the time-of-day and day-of-week bar charts.

  • Step 1: Tests for each calculator

Cover the happy path, empty data, and a bullet assertion that no N+1 occurs in TimeDistribution#call (when iterating its result, no extra queries fire).

  • Step 2: Implement, get GREEN, commit per file
git commit -am "feat: focus session stats, distribution, patterns"

Task 7: Snapshot Facade

Files:

  • Create: app/models/focus_session/snapshot.rb

  • Step 1: Implement

class FocusSession::Snapshot
  attr_reader :user

  def initialize(user, status: nil, window: nil)
    @user   = user
    @status = (FocusSession.statuses.keys.include?(status) ? status : nil)
    @window = window
  end

  def hero_total_minutes  = user.focus_sessions.completed_sessions.sum(:duration_seconds) / 60
  def hero_session_count  = user.focus_sessions.completed_sessions.count
  def hero_best_minutes   = user.focus_sessions.completed_sessions.maximum(:duration_seconds).to_i / 60
  def heatmap             = FocusSession::Heatmap.new(user).call
  def recent_sessions(limit: 20) = filter(user.focus_sessions.includes(:focusable)).order(started_at: :desc).limit(limit)
  def stats               = @stats               ||= FocusSession::Stats.new(user)
  def time_distribution(kind: :tasks) = FocusSession::TimeDistribution.new(user, kind:).call
  def patterns            = @patterns            ||= FocusSession::Patterns.new(user)
  def active_session      = user.focus_sessions.where(status: %w[running paused]).order(started_at: :desc).first

  private

  def filter(scope)
    scope = scope.where(status: @status) if @status
    case @window
    when "today" then scope.where(started_at: Time.current.all_day)
    when "week"  then scope.where(started_at: Time.current.all_week)
    when "month" then scope.where(started_at: Time.current.all_month)
    else scope
    end
  end
end
  • Step 2: Commit
git commit -am "feat: focus snapshot facade"

Task 8: Views — Hero, Sessions List, Stats, Distribution, Patterns, Export

Files:

  • Create: app/views/focus/_hero.html.erb
  • Create: app/views/focus/_session_row.html.erb
  • Create: app/views/focus/_sessions_list.html.erb
  • Create: app/views/focus/_stat_cards.html.erb
  • Create: app/views/focus/_time_distribution.html.erb
  • Create: app/views/focus/_session_stats.html.erb
  • Create: app/views/focus/_focus_patterns.html.erb
  • Create: app/views/focus/_export.html.erb
  • Modify: app/views/focus/show.html.erb

  • Step 1: Build each partial

Mirror the loggd layout from the project plan section "Page Sections". Each partial pulls only the data it needs from @snapshot.

The 365-day heatmap reuses the same emerald 5-step palette as the landing page's heatmap (DRY: declare the levels once in CSS).

_session_row.html.erb is reused everywhere a session is listed (sessions list, time distribution drilldown, edit screen header).

  • Step 2: Wire show.html.erb
<section class="mx-auto max-w-6xl px-4 py-8 space-y-6">
  <header class="flex items-center justify-between">
    <div>
      <h1 class="text-3xl font-bold"><%= t("focus.title") %></h1>
      <p class="text-gray-400"><%= t("focus.subtitle") %></p>
    </div>
    <%= button_tag t("focus.start_timer"), data: { controller: "focus-start-modal", action: "click->focus-start-modal#open" } %>
  </header>

  <%= render "hero", snapshot: @snapshot %>
  <%= render "sessions_list", snapshot: @snapshot %>
  <%= render "stat_cards", snapshot: @snapshot %>
  <%= render "time_distribution", snapshot: @snapshot %>
  <%= render "session_stats", snapshot: @snapshot %>
  <%= render "focus_patterns", snapshot: @snapshot %>
  <%= render "export", snapshot: @snapshot %>
  <%= render "start_modal" %>
</section>
  • Step 3: Commit
git commit -am "feat: focus page partials"

Task 9: Stimulus Timer Controller

Files:

  • Create: app/javascript/controllers/focus_timer_controller.js
  • Create: app/views/focus/_active_widget.html.erb
  • Create: app/views/layouts/_active_focus_widget.html.erb
  • Modify: app/views/layouts/application.html.erb

  • Step 1: Implement
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["countdown"]
  static values  = {
    sessionId: Number,
    startedAt: String,
    plannedSeconds: Number,
    pausedSeconds: { type: Number, default: 0 },
    pausedAt: String,
    status: String
  }

  connect() {
    this.tick()
    this.interval = setInterval(() => this.tick(), 1000)
    this.poll = setInterval(() => this.reconcile(), 30000)
    this.persist()
  }

  disconnect() {
    if (this.interval) clearInterval(this.interval)
    if (this.poll) clearInterval(this.poll)
  }

  tick() {
    const elapsed = this.elapsedSeconds()
    const remaining = Math.max(this.plannedSecondsValue - elapsed, 0)
    this.countdownTarget.textContent = this.format(remaining)
    if (remaining === 0 && this.statusValue !== "completed") this.complete()
  }

  elapsedSeconds() {
    const start = new Date(this.startedAtValue)
    let raw = (Date.now() - start.getTime()) / 1000 - this.pausedSecondsValue
    if (this.statusValue === "paused" && this.pausedAtValue) {
      const pausedAt = new Date(this.pausedAtValue)
      raw -= Math.max(0, (Date.now() - pausedAt.getTime()) / 1000)
    }
    return Math.max(Math.floor(raw), 0)
  }

  format(s) {
    const m = Math.floor(s / 60), r = s % 60
    return `${String(m).padStart(2, "0")}:${String(r).padStart(2, "0")}`
  }

  async reconcile() {
    const res = await fetch(`/focus_sessions/active`, { headers: { Accept: "text/html" } })
    if (res.status === 204) { this.element.remove(); return }
    if (res.ok) this.element.outerHTML = await res.text()
  }

  async complete() {
    if (this.completing) return
    this.completing = true
    await fetch(`/focus_sessions/${this.sessionIdValue}`, {
      method: "PATCH",
      headers: { "Content-Type": "application/json", "X-CSRF-Token": document.querySelector('meta[name=csrf-token]')?.content },
      body: JSON.stringify({ transition: "complete" })
    })
    this.beep()
  }

  beep() {
    try {
      const audio = new Audio("/assets/focus_complete.mp3")
      audio.play().catch(() => {})
    } catch (_) {}
  }

  persist() {
    localStorage.setItem("focus.activeSession", JSON.stringify({
      id: this.sessionIdValue, startedAt: this.startedAtValue, status: this.statusValue
    }))
  }
}
  • Step 2: Active widget partial
<%# app/views/focus/_active_widget.html.erb %>
<%# locals: session: %>
<aside class="fixed bottom-4 right-4 min-w-72 rounded-2xl bg-gray-900 text-white p-4 shadow-2xl"
       data-controller="focus-timer"
       data-focus-timer-session-id-value="<%= session.id %>"
       data-focus-timer-started-at-value="<%= session.started_at.iso8601 %>"
       data-focus-timer-planned-seconds-value="<%= session.planned_seconds %>"
       data-focus-timer-paused-seconds-value="<%= session.paused_seconds %>"
       data-focus-timer-paused-at-value="<%= session.paused_at&.iso8601 %>"
       data-focus-timer-status-value="<%= session.status %>">

  <h3 class="text-sm uppercase opacity-60"><%= t("focus.active_widget.title") %></h3>
  <p class="text-3xl font-mono mt-1" data-focus-timer-target="countdown">…</p>
  <% if session.focusable %>
    <p class="text-sm opacity-80 mt-1"><%= focusable_label(session.focusable) %></p>
  <% end %>
  <div class="flex gap-2 mt-3">
    <%= button_to t("focus.pause"),    focus_session_path(session, transition: "pause"),    method: :patch %>
    <%= button_to t("focus.complete"), focus_session_path(session, transition: "complete"), method: :patch %>
    <%= button_to t("focus.stop"),     focus_session_path(session, transition: "stop"),     method: :patch %>
  </div>
</aside>
  • Step 3: Mount globally

In app/views/layouts/application.html.erb, before </body>:

<turbo-frame id="active_focus_widget" src="<%= active_focus_sessions_path %>" loading="lazy"></turbo-frame>

The active action either renders the widget or 204 No Content.

  • Step 4: Commit
git commit -am "feat: focus timer Stimulus widget with reconcile"

Task 10: Start Modal

Files:

  • Create: app/views/focus/_start_modal.html.erb
  • Create: app/javascript/controllers/focus_start_modal_controller.js

  • Step 1: Implement modal

Tabs: Free / Task / Habit / Goal. The active tab swaps the picker (a select2-style searchable dropdown scoped to current_user.<assoc>). Customize dropdown lists presets + Custom (which reveals a number input bounded 1..480 minutes, validated client-side).

Submit posts to POST /focus_sessions with mode, planned_seconds, focusable_type, focusable_id.

  • Step 2: Stimulus
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["dialog", "tab", "panel", "customMinutes", "form"]

  open() { this.dialogTarget.showModal() }
  close() { this.dialogTarget.close() }

  switchTab(event) {
    const kind = event.currentTarget.dataset.kind
    this.tabTargets.forEach(t => t.classList.toggle("active", t.dataset.kind === kind))
    this.panelTargets.forEach(p => p.classList.toggle("hidden", p.dataset.kind !== kind))
  }
}
  • Step 3: Commit
git commit -am "feat: focus start timer modal"

Task 11: CSV Export (Streaming)

Files:

  • Create: app/models/focus_session/exporter.rb
  • Create: test/models/focus_session/exporter_test.rb

  • Step 1: Failing test
require "test_helper"

class FocusSession::ExporterTest < ActiveSupport::TestCase
  test "streams rows in CSV order without loading all into memory" do
    rows = FocusSession::Exporter.new(users(:admin), range: "all").each.to_a
    headers, *body = rows.map { |line| line }
    assert_match(/started_at,ended_at,duration_seconds,mode,status,focusable_type,focusable_id,focusable_label,note,xp_awarded/, headers)
    assert body.size >= 1
  end

  test "respects range param" do
    rows = FocusSession::Exporter.new(users(:admin), range: "week").each.to_a
    assert rows.size >= 1  # header always present
  end
end
  • Step 2: Implement
class FocusSession::Exporter
  include Enumerable

  HEADERS = %w[started_at ended_at duration_seconds mode status focusable_type focusable_id focusable_label note xp_awarded].freeze

  def initialize(user, range: "all")
    @user  = user
    @range = range.to_s
  end

  def each(&block)
    return enum_for(:each) unless block

    yield CSV.generate_line(HEADERS)

    scope.find_each(batch_size: 500) do |session|
      yield CSV.generate_line([
        session.started_at.iso8601,
        session.ended_at&.iso8601,
        session.duration_seconds,
        session.mode,
        session.status,
        session.focusable_type,
        session.focusable_id,
        session.focusable&.try(:title) || session.focusable&.try(:name),
        session.note,
        session.xp_awarded
      ])
    end
  end

  private

  def scope
    s = @user.focus_sessions.completed_sessions.includes(:focusable).order(:started_at)
    case @range
    when "month" then s.where(started_at: Time.current.all_month)
    when "week"  then s.where(started_at: Time.current.all_week)
    else s
    end
  end
end
  • Step 3: Tests pass + commit
bin/rails test test/models/focus_session/exporter_test.rb
git commit -am "feat: focus CSV exporter with streaming"

Same pattern as Leaderboard task 12. Add Timer link with a stopwatch icon.

git commit -am "feat: focus navigation links"

Task 13: Rate Limiting

Files:

  • Modify: config/initializers/rack_attack.rb

  • Step 1: Throttle session creation and CSV export

Rack::Attack.throttle("focus_sessions/create", limit: 30, period: 1.minute) do |req|
  req.env["warden"].user&.id if req.path == "/focus_sessions" && req.post?
end

Rack::Attack.throttle("focus_sessions/export", limit: 5, period: 1.minute) do |req|
  req.env["warden"].user&.id if req.path == "/focus_sessions/export"
end
  • Step 2: Commit
git commit -am "feat: focus rate limiting"

Task 14: Audio Asset

  • Step 1: Add app/assets/audios/focus_complete.mp3

A short (1-2s) soft beep. Public-domain or self-generated. Confirm Propshaft serves audio assets correctly.

  • Step 2: Verify
ls app/assets/audios/focus_complete.mp3
  • Step 3: Commit
git commit -am "feat: focus completion beep asset"

Task 15: Final Verification

  • Step 1: Targeted tests
bin/rails test test/controllers/focus_controller_test.rb \
               test/controllers/focus_sessions_controller_test.rb \
               test/models/focus_session_test.rb \
               test/models/focus_session
  • Step 2: Brakeman
bundle exec brakeman -q

Confirm no new warnings, especially around polymorphic focusable_id resolution.

  • Step 3: Full CI
bin/ci
  • Step 4: Manual smoke (optional)

Open /focus, start a 25-minute Pomodoro, link to a TodoCard, verify the docked widget appears, refresh the page, confirm widget persists, complete the session, confirm an XpTransaction row exists and the user's level XP increased.