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, plusCustom.... Start Timerconfirms and creates theFocusSession.
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_xpandtotal_xpcolumns onGamificationProfile. - Writes an
XpTransactionrow withcategory: "focus",description: "Completed a #{minutes}-minute focus session",source: session. The leaderboard activity feed picks it up automatically via the existingXpTransactionindex.
Performance
- All queries scoped by
user_idfirst; 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 viasolid_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/activeevery 30s only when running; not running, no requests. - CSV export streams rows via
ActiveRecord::Batches.find_each(batch_size: 500)withActionController::Liveso memory stays flat for users with thousands of sessions.
Security
- Strong params lock
modeto the known enum values. focusable_typeis checked against an allowlist (%w[TodoCard Habit Goal]); anything else is rejected with 422 before instantiation.focusable_idis loaded throughcurrent_user.<assoc>.find(id), neverKlass.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-formstatus=write. duration_secondsandpaused_secondsare recomputed server-side fromstarted_at,paused_at, and the request timestamp; the client cannot inflate the value to farm XP.- Rate-limit
POST /focus_sessionsto 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:
64pxtall, 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
/focusrenders all sections. - Starting a session creates a
FocusSessionwithstatus: running,started_atset, planned_seconds matching the chosen mode. - Completing a session sets
status: completed, recomputesduration_secondsfrom server time, and creates oneXpTransaction. - Pause then resume updates
paused_secondscorrectly. - Stop transitions to
stoppedwithout awarding XP. - Cross-user
focusable_idis rejected. - Invalid
focusable_typeis rejected. - 8-hour cap on
planned_secondsrejects 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_sessionstable with all indexes. - Add
focus_xptogamification_profiles. FocusSessionmodel with enums, validations, scopes.- Polymorphic constraint allowlist test.
Phase 2 β Routing, Controllers, State Machine
- Routes and policies.
FocusController#showthin shell.FocusSessionsControllerwith explicitpause!/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::XpAwarderwithaward_focus_completion. - Hook into
FocusSession#complete!. - Add
focuscategory toXpTransaction. - 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_preferencesmodel with a newfocus_session_completedchannel. - Whether to support multiple concurrent sessions. Initial plan: no β only one
runningorpausedper 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_visibleuser preference (defaulttruefor users on the leaderboard,falseotherwise).