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_typeallowlist, cross-userfocusable_idrejection, server-side recomputation ofduration_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(addhas_many :focus_sessions, dependent: :destroy) - Modify:
app/models/gamification/xp_awarder.rb(addaward_focus_completion) - Modify:
app/models/gamification_profile.rb(focus_xpaccessor) - 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"
Task 12: Sidebar / Navbar / Footer Links
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.