Focus Timer Project Plan

Goal

Add an authenticated /focus page where each user can run timed focus sessions (Pomodoro presets or custom), optionally linked to a Task, Habit, or Goal, and review their focus history through a 365-day heatmap, session list, and statistics. Completed sessions award XP through the existing gamification pipeline so they appear automatically in the leaderboard activity feed.

Inspiration

Visual and structural reference: the loggd.life focus timer (https://loggd.life/@demo/focus). We mirror its hero activity card, sessions list with status/time filters, four-stat grid, time distribution panel, session statistics, focus patterns, and CSV export. We adapt the "Link To" tab from Free / Task / Habit / Tags to Free / Task / Habit / Goal, since Lifehub has Goals but not free-form tags.

User Experience

The first screen should answer:

  • How much focused time did I put in today / this week / all time?
  • What's running right now?
  • What did I focus on?
  • When am I most productive?

The page is a stats dashboard above and a runtime widget on demand. Starting a session opens a modal; the active session is rendered in a docked widget that survives page reloads via local-storage time tracking.

Route

resource  :focus, only: %i[show],   controller: "focus"
resources :focus_sessions, except: %i[show new]

URLs:

  • GET /focus β€” main stats dashboard.
  • POST /focus_sessions β€” start a new session.
  • PATCH /focus_sessions/:id β€” pause / resume / complete / stop.
  • DELETE /focus_sessions/:id β€” delete a recorded session.
  • GET /focus_sessions/:id/edit β€” edit note or recategorize after completion.
  • GET /focus_sessions/export.csv β€” CSV download.

Page Sections

Hero Activity Card

Total focus hours, total sessions, best single session, plus a 53Γ—7 contribution heatmap of the last 365 days. Tooltip on each cell shows the date and total minutes.

Start Timer Modal

Triggered by a + Track button in the page header.

  • Mode tab: Free / Task / Habit / Goal.
  • For Task/Habit/Goal, a search/select picker scoped to the current user's records.
  • Customize dropdown: presets Pomodoro 25m, Pomodoro 50m, Quick 15m, Deep 90m, plus Custom....
  • Start Timer confirms and creates the FocusSession.

Active Session Widget

Rendered as a docked card at the bottom-right of every authenticated page (not just /focus) when a session is running. Shows remaining time, mode, optional task/habit/goal label, pause/resume button, complete button, stop button.

The countdown ticks via Stimulus + setInterval. The remaining time is computed from started_at + duration_seconds - elapsed_paused_seconds so refresh and tab switching never lose state. The current state (running/paused, last paused-at timestamp) is persisted in localStorage and reconciled with the server every 30s.

Completion plays a soft beep (small audio asset) and triggers a Turbo Stream that posts a XpTransaction and updates the page in place.

Sessions List

Filterable list of recent sessions:

  • Status filter chips: All / Done / Stopped.
  • Time filter chips: Today / Week / Month / All.
  • Each row: linked entity badge, title, mode chip, started time, duration, +XP, edit, delete.
  • Pagination via Pagy at 20 per page.

Four Stat Cards

  • Streak β€” consecutive days with at least one completed session, plus best-ever streak.
  • This Week β€” total minutes, with Β±N% vs last week.
  • Peak Time β€” top time-of-day bucket (Morning/Afternoon/Evening/Night) for the last 30 days.
  • Best Day β€” top day-of-week with average session length.

Time Distribution

Tabs: Tasks / Habits / Goals / Free. For the active tab, list the top 5–10 entities by minutes spent, with a horizontal progress bar per row. Total at the bottom.

Session Stats

Completion %, average minutes, longest minutes, session-length histogram (bins: <15, 15–30, 30–45, 45–60, 60+), completed vs cancelled count, favorite preset.

Focus Patterns

  • Time-of-day bar chart (Morning 5–12, Afternoon 12–17, Evening 17–22, Night 22–5).
  • Day-of-week bar chart.
  • Insight line: You're most productive on <day> during the <time>.

Export Data

Range chips (All Time / This Month / This Week / Custom). Custom opens a date-range modal. CSV columns: started_at, ended_at, duration_seconds, mode, status, focusable_type, focusable_id, focusable_label, note, xp_awarded.

Data Model

create_table :focus_sessions do |t|
  t.references :user, null: false, foreign_key: true, index: true
  t.string  :focusable_type
  t.bigint  :focusable_id
  t.string  :mode,     null: false                    # free | pomodoro_25 | pomodoro_50 | quick_15 | deep_90 | custom
  t.string  :status,   null: false, default: "running" # running | paused | completed | stopped
  t.datetime :started_at, null: false
  t.datetime :ended_at
  t.integer  :planned_seconds, null: false             # original target
  t.integer  :duration_seconds, null: false, default: 0 # actual elapsed at last update
  t.integer  :paused_seconds,   null: false, default: 0 # cumulative pause time
  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]

add_column :gamification_profiles, :focus_xp, :integer, default: 0, null: false

class FocusSession < ApplicationRecord
  belongs_to :user
  belongs_to :focusable, polymorphic: true, optional: true   # TodoCard | Habit | Goal

  enum :mode,   { free: "free", pomodoro_25: "pomodoro_25", pomodoro_50: "pomodoro_50",
                  quick_15: "quick_15", deep_90: "deep_90", custom: "custom" }
  enum :status, { running: "running", paused: "paused", completed: "completed", stopped: "stopped" }

  validates :planned_seconds,  numericality: { greater_than: 0, less_than_or_equal_to: 8.hours.to_i }
  validates :duration_seconds, numericality: { greater_than_or_equal_to: 0 }

  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) }
end

Polymorphic focusable is constrained at the controller layer to one of TodoCard | Habit | Goal to prevent attackers from linking sessions to arbitrary records. The constraint is enforced inside FocusSessionsController#create_params and rejected with 422 if violated.

Architecture

app/controllers/focus_controller.rb                 # GET /focus
app/controllers/focus_sessions_controller.rb        # CRUD + state transitions
app/models/focus_session.rb
app/models/focus_session/snapshot.rb                # page-level facade
app/models/focus_session/heatmap.rb                 # 365-day grid
app/models/focus_session/stats.rb                   # streak, week-over-week, peak time
app/models/focus_session/time_distribution.rb       # by task / habit / goal / free
app/models/focus_session/patterns.rb                # time-of-day, day-of-week
app/models/focus_session/exporter.rb                # CSV
app/policies/focus_policy.rb
app/policies/focus_session_policy.rb
app/views/focus/show.html.erb
app/views/focus/_hero.html.erb
app/views/focus/_sessions_list.html.erb
app/views/focus/_stat_cards.html.erb
app/views/focus/_time_distribution.html.erb
app/views/focus/_session_stats.html.erb
app/views/focus/_focus_patterns.html.erb
app/views/focus/_export.html.erb
app/views/focus/_active_widget.html.erb
app/views/focus/_start_modal.html.erb
app/javascript/controllers/focus_timer_controller.js
app/javascript/controllers/focus_start_modal_controller.js
app/javascript/controllers/focus_heatmap_tooltip_controller.js

FocusController#show stays thin:

class FocusController < ApplicationController
  def show
    authorize :focus
    @snapshot = FocusSession::Snapshot.new(current_user, status: params[:status], window: params[:window])
  end
end

FocusSessionsController handles state transitions through PATCH actions. Each transition validates the new state against the current one (e.g., a completed session can't go back to running).

Authorization

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

The polymorphic focusable is also authorized: when linking, we verify that the chosen TodoCard/Habit/Goal belongs_to the current user.

XP Integration

Add Gamification::XpAwarder.award_focus_completion(user, session):

  • Award based on actual completed minutes (not planned), with diminishing returns:
    • First 25 min: 1 XP per 5 min (max 5).
    • 25–60 min: 1 XP per 10 min (max 4).
    • 60+ min: 1 XP per 30 min (cap at +6).
    • Total cap per session: 15 XP. Prevents farming with 8-hour sessions.
  • Adds focus_xp and total_xp columns on GamificationProfile.
  • Writes an XpTransaction row with category: "focus", description: "Completed a #{minutes}-minute focus session", source: session. The leaderboard activity feed picks it up automatically via the existing XpTransaction index.

Performance

  • All queries scoped by user_id first; the (user_id, started_at) index makes month/week ranges fast.
  • 365-day heatmap query: SELECT date(started_at), SUM(duration_seconds) grouped, indexed scan over one year per user. Result cached per-user for 5 minutes via solid_cache, busted on session create/update/destroy.
  • Time distribution joins with TodoCard/Habit/Goal use polymorphic includes via preload(:focusable) keyed by type and id; no N+1.
  • Active session widget polls /focus_sessions/active every 30s only when running; not running, no requests.
  • CSV export streams rows via ActiveRecord::Batches.find_each(batch_size: 500) with ActionController::Live so memory stays flat for users with thousands of sessions.

Security

  • Strong params lock mode to the known enum values.
  • focusable_type is checked against an allowlist (%w[TodoCard Habit Goal]); anything else is rejected with 422 before instantiation.
  • focusable_id is loaded through current_user.<assoc>.find(id), never Klass.find, ensuring cross-user linking is impossible.
  • State transitions are explicit: each PATCH has a transition method (pause!, resume!, complete!, stop!) that raises if the target state is invalid for the current state. The controller does not accept a free-form status= write.
  • duration_seconds and paused_seconds are recomputed server-side from started_at, paused_at, and the request timestamp; the client cannot inflate the value to farm XP.
  • Rate-limit POST /focus_sessions to 30 req/min/user via Rack::Attack.
  • CSV export rate-limited (5/min/user) since it hits the disk.

Visual Style

Dark-first, matching dashboard:

  • Hero activity card: large total hours number, secondary line with sessions count and best, heatmap below.
  • Sessions list rows: 64px tall, linked-entity chip, title, mode chip, time, duration on the right.
  • Stat cards: 4 columns on desktop, 2Γ—2 on tablet, single column on mobile.
  • Active session widget: bottom-right, min-w-72, large monospaced countdown, three buttons (pause / complete / stop) with appropriate accent colors.
  • Heatmap: same emerald 5-step scale used on the landing page year-grid for consistency.
  • Modal: dark scrim, centered card, focus mode tabs at top, picker below.

Internationalization

config/locales/en.yml and config/locales/pt-BR.yml:

  • focus.title, focus.subtitle
  • Section titles: focus.hero, focus.sessions, focus.stats, focus.distribution, focus.patterns, focus.export.
  • Mode names: focus.modes.free / pomodoro_25 / ...
  • Status labels.
  • XP description keys (focus.xp.completed_session).
  • Insight string template.

Testing Plan

  • Authenticated user GET /focus renders all sections.
  • Starting a session creates a FocusSession with status: running, started_at set, planned_seconds matching the chosen mode.
  • Completing a session sets status: completed, recomputes duration_seconds from server time, and creates one XpTransaction.
  • Pause then resume updates paused_seconds correctly.
  • Stop transitions to stopped without awarding XP.
  • Cross-user focusable_id is rejected.
  • Invalid focusable_type is rejected.
  • 8-hour cap on planned_seconds rejects 9-hour sessions.
  • XP cap of 15 per session enforced regardless of duration.
  • Heatmap returns a 365-key map; missing days zero-filled.
  • Stats handle empty data without raising.
  • CSV export streams without loading all rows into memory (assert with count_queries).
  • Bullet asserts no N+1 on the sessions list.
bin/rails test test/controllers/focus_controller_test.rb
bin/rails test test/controllers/focus_sessions_controller_test.rb
bin/rails test test/models/focus_session_test.rb test/models/focus_session
bin/ci

Implementation Phases

Phase 1 β€” Model & Migration

  • Create focus_sessions table with all indexes.
  • Add focus_xp to gamification_profiles.
  • FocusSession model with enums, validations, scopes.
  • Polymorphic constraint allowlist test.

Phase 2 β€” Routing, Controllers, State Machine

  • Routes and policies.
  • FocusController#show thin shell.
  • FocusSessionsController with explicit pause! / resume! / complete! / stop! transitions.

Phase 3 β€” Snapshot, Heatmap, Stats, Distribution, Patterns

  • Build each calculator as its own namespaced model class with focused tests.
  • Wire them to the snapshot facade.

Phase 4 β€” Frontend Timer

  • focus_timer_controller.js (countdown with localStorage state).
  • focus_start_modal_controller.js.
  • Active session widget partial wired into application.html.erb.

Phase 5 β€” XP Integration

  • Extend Gamification::XpAwarder with award_focus_completion.
  • Hook into FocusSession#complete!.
  • Add focus category to XpTransaction.
  • Verify activity feed picks up new events.

Phase 6 β€” Export & Polish

  • CSV streaming export.
  • Rate limits.
  • Caching for heatmap.
  • Run bin/ci.

Open Decisions

  • Whether to send a browser notification on completion. Initial plan: yes, opt-in via the existing notification_preferences model with a new focus_session_completed channel.
  • Whether to support multiple concurrent sessions. Initial plan: no β€” only one running or paused per user; starting another auto-stops the previous. This matches the "single focus" spirit of Pomodoro.
  • Whether to expose the focus heatmap on the public profile page. Initial plan: yes, behind a focus_heatmap_visible user preference (default true for users on the leaderboard, false otherwise).