Gamer Dashboard 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 an authenticated /gamer dashboard that turns Lifehub tasks, habits, finances, goals, sports, XP, achievements, challenges, and levels into a pixel RPG command center.

Architecture: Add a thin GamerDashboardController, a singleton Pundit policy, and a namespaced GamerDashboard::Snapshot model that aggregates current-user data for the view. Render the page with focused ERB partials and project-bound generated PNG assets for the level characters and pixel-art world map.

Tech Stack: Rails 8.1, Pundit, Minitest fixtures, Turbo-compatible ERB, Tailwind CSS v4, Propshaft image assets, built-in image generation for PNG assets.


File Structure

  • Create: app/controllers/gamer_dashboard_controller.rb
    • Thin HTTP controller for /gamer.
  • Create: app/models/gamer_dashboard/snapshot.rb
    • Aggregates current-user dashboard data and level asset selection.
  • Create: app/policies/gamer_dashboard_policy.rb
    • Singleton page authorization.
  • Create: app/helpers/gamer_dashboard_helper.rb
    • Small view helpers for quest paths, progress widths, and rarity colors if needed by partials.
  • Create: app/views/gamer_dashboard/show.html.erb
    • Full page layout.
  • Create: app/views/gamer_dashboard/_player_panel.html.erb
    • Player card, current sprite, level, XP, achievements.
  • Create: app/views/gamer_dashboard/_world_map.html.erb
    • Generated map background with clickable zones.
  • Create: app/views/gamer_dashboard/_quest_feed.html.erb
    • Combined task, habit, challenge, goal, and training feed.
  • Create: app/views/gamer_dashboard/_finance_panel.html.erb
    • Finance Vault panel.
  • Create: app/views/gamer_dashboard/_habits_panel.html.erb
    • Habit Forest panel.
  • Create: app/views/gamer_dashboard/_tasks_panel.html.erb
    • Task Board panel.
  • Create: app/views/gamer_dashboard/_goals_panel.html.erb
    • Goal Temple panel.
  • Create: app/views/gamer_dashboard/_sports_panel.html.erb
    • Training Grounds panel.
  • Create: app/views/gamer_dashboard/_gamification_panel.html.erb
    • Challenge, achievement, XP, and next-level panel.
  • Create: app/assets/images/gamer/characters/rookie.png
  • Create: app/assets/images/gamer/characters/scout.png
  • Create: app/assets/images/gamer/characters/adventurer.png
  • Create: app/assets/images/gamer/characters/specialist.png
  • Create: app/assets/images/gamer/characters/master.png
  • Create: app/assets/images/gamer/characters/grand_master.png
  • Create: app/assets/images/gamer/characters/sage.png
  • Create: app/assets/images/gamer/characters/sage_elite.png
  • Create: app/assets/images/gamer/characters/legend.png
  • Create: app/assets/images/gamer/characters/mythic_legend.png
  • Create: app/assets/images/gamer/characters/transcendent.png
  • Create: app/assets/images/gamer/maps/lifehub-world.png
  • Modify: config/routes.rb
    • Add authenticated /gamer route.
  • Modify: app/views/shared/_sidebar.html.erb
    • Add Gamer link in the gamification/navigation area.
  • Modify: app/views/shared/_navbar.html.erb
    • Add Gamer link in the desktop navigation menu.
  • Modify: app/views/shared/_footer_nav.html.erb
    • Add Gamer link in the mobile More drawer.
  • Modify: app/helpers/application_helper.rb
    • Add gamer icon case to sidebar_icon and nav_link icon handling.
  • Modify: config/locales/en.yml
    • Add nav.gamer and gamer_dashboard.*.
  • Modify: config/locales/pt-BR.yml
    • Add nav.gamer and gamer_dashboard.*.
  • Modify: app/assets/tailwind/application.css
    • Add scoped pixel dashboard CSS classes.
  • Create: test/controllers/gamer_dashboard_controller_test.rb
  • Create: test/models/gamer_dashboard/snapshot_test.rb

Task 1: Generate Project-Bound Pixel Assets

Files:

  • Create: app/assets/images/gamer/characters/*.png
  • Create: app/assets/images/gamer/maps/lifehub-world.png

  • Step 1: Create asset folders

Run:

mkdir -p app/assets/images/gamer/characters app/assets/images/gamer/maps

Expected: both directories exist.

  • Step 2: Generate the pixel-art map PNG

Use the built-in image generation tool with this prompt:

Use case: stylized-concept
Asset type: Rails dashboard background image, saved as app/assets/images/gamer/maps/lifehub-world.png
Primary request: Create a crisp pixel-art-inspired isometric RPG world map for a personal life management dashboard called Lifehub.
Scene/backdrop: A bright fantasy island map viewed from above at a three-quarter angle, with distinct clickable-looking zones.
Required zones: Task Board village with small cards and columns, Habit Forest with ritual stones, Finance Vault with a glowing bank/treasury, Goal Temple with a target monument, Training Grounds with a tiny track and gym area, Challenge Arena, Achievement Hall, and Level Shrine.
Style: 16-bit RPG map aesthetic, clean readable shapes, rich but balanced colors, no text, no logos, no real brand references, no UI chrome.
Composition: Wide dashboard hero image, 16:9 aspect ratio, important landmarks spread across the frame with room for overlay labels.
Avoid: GitHub logos, copyrighted characters, blurry painterly style, photorealism, text labels, watermark.

Save the final selected image as:

app/assets/images/gamer/maps/lifehub-world.png

Expected: lifehub-world.png is a repository asset and can be referenced with image_path("gamer/maps/lifehub-world.png").

  • Step 3: Generate eleven 16x16-style character PNGs

Generate one asset per tier. Use the same base style for every prompt:

Use case: stylized-concept
Asset type: Tiny pixel RPG character sprite for Rails app UI.
Style: 16x16-bit inspired pixel character, full body, front-facing, crisp silhouette, transparent-looking isolated subject on a simple flat dark navy background, no text, no watermark, no logo, no real brand references.
Output expectation: PNG file suitable for app/assets/images/gamer/characters/.

Add this tier-specific line to each generation:

Rookie: starter life-management adventurer, simple outfit, small satchel, friendly expression, modest colors.
Scout: upgraded adventurer with cap, compact backpack, confident stance, brighter trim.
Adventurer: stronger pose, utility belt, task scroll, energetic colors.
Specialist: finance, task, and habit hybrid gear, small ledger, badge, focused expression.
Master: refined armor, bright accent trim, polished posture, experienced look.
Grand Master: ornate accessory, short cape, gold detail, calm authority.
Sage: strategic magical look, soft aura, robe-like adventurer outfit, thoughtful expression.
Sage Elite: stronger aura, rare colors, star-like detail, advanced gear.
Legend: heroic silhouette, high-tier outfit, glowing accent, confident stance.
Mythic Legend: elevated legendary outfit, stronger glow, special gear, rare palette.
Transcendent: final unique character, luminous details, balanced high-level aura, still readable as a tiny sprite.

Save each final PNG to:

app/assets/images/gamer/characters/rookie.png
app/assets/images/gamer/characters/scout.png
app/assets/images/gamer/characters/adventurer.png
app/assets/images/gamer/characters/specialist.png
app/assets/images/gamer/characters/master.png
app/assets/images/gamer/characters/grand_master.png
app/assets/images/gamer/characters/sage.png
app/assets/images/gamer/characters/sage_elite.png
app/assets/images/gamer/characters/legend.png
app/assets/images/gamer/characters/mythic_legend.png
app/assets/images/gamer/characters/transcendent.png

Expected: all character filenames match the tier names returned by GamerDashboard::Snapshot#character_tier.

  • Step 4: Verify asset files exist

Run:

ls app/assets/images/gamer/characters app/assets/images/gamer/maps

Expected: the eleven character files and lifehub-world.png are listed.

  • Step 5: Commit assets

Run:

git add app/assets/images/gamer
git commit -m "feat: add gamer dashboard pixel assets"

Expected: commit succeeds with only generated image assets staged.


Task 2: Add Route, Policy, Controller, and Access Tests

Files:

  • Modify: config/routes.rb
  • Create: app/controllers/gamer_dashboard_controller.rb
  • Create: app/policies/gamer_dashboard_policy.rb
  • Create: test/controllers/gamer_dashboard_controller_test.rb

  • Step 1: Write the failing controller test

Create test/controllers/gamer_dashboard_controller_test.rb:

require "test_helper"

class GamerDashboardControllerTest < ActionDispatch::IntegrationTest
  include Devise::Test::IntegrationHelpers

  test "authenticated user can access gamer dashboard" do
    sign_in users(:admin)

    get gamer_url

    assert_response :success
    assert_select "h1", I18n.t("gamer_dashboard.title")
  end

  test "unauthenticated user is redirected" do
    get gamer_url

    assert_response :redirect
    assert_redirected_to new_user_session_path
  end

  test "route maps to gamer dashboard show" do
    assert_routing "/gamer", controller: "gamer_dashboard", action: "show"
  end
end
  • Step 2: Run test to verify it fails

Run:

bin/rails test test/controllers/gamer_dashboard_controller_test.rb

Expected: FAIL because gamer_url and GamerDashboardController do not exist.

  • Step 3: Add the authenticated route

In config/routes.rb, inside the authenticated :user do block, immediately after get "dashboard", to: "dashboard#index" add:

get "gamer", to: "gamer_dashboard#show"
  • Step 4: Add the policy

Create app/policies/gamer_dashboard_policy.rb:

class GamerDashboardPolicy < ApplicationPolicy
  def show? = user.present?
end
  • Step 5: Add the controller

Create app/controllers/gamer_dashboard_controller.rb:

class GamerDashboardController < ApplicationController
  def show
    authorize :gamer_dashboard
    @snapshot = GamerDashboard::Snapshot.new(current_user)
  end
end
  • Step 6: Add a temporary minimal view so the route can render

Create app/views/gamer_dashboard/show.html.erb:

<h1><%= t("gamer_dashboard.title") %></h1>
  • Step 7: Add minimal English and Portuguese translations

Add under en: in config/locales/en.yml:

  gamer_dashboard:
    title: Gamer Dashboard

Add under pt-BR: in config/locales/pt-BR.yml:

  gamer_dashboard:
    title: Painel Gamer
  • Step 8: Run controller test

Run:

bin/rails test test/controllers/gamer_dashboard_controller_test.rb

Expected: FAIL because GamerDashboard::Snapshot does not exist.

  • Step 9: Commit route/controller/policy shell

Run after Task 3 makes the tests pass:

git add config/routes.rb app/controllers/gamer_dashboard_controller.rb app/policies/gamer_dashboard_policy.rb app/views/gamer_dashboard/show.html.erb config/locales/en.yml config/locales/pt-BR.yml test/controllers/gamer_dashboard_controller_test.rb
git commit -m "feat: add gamer dashboard route"

Expected: commit succeeds.


Task 3: Build the Snapshot Data Model With Tests

Files:

  • Create: app/models/gamer_dashboard/snapshot.rb
  • Create: test/models/gamer_dashboard/snapshot_test.rb

  • Step 1: Write the failing snapshot test

Create test/models/gamer_dashboard/snapshot_test.rb:

require "test_helper"

class GamerDashboard::SnapshotTest < ActiveSupport::TestCase
  setup do
    @user = users(:admin)
    @snapshot = GamerDashboard::Snapshot.new(@user)
  end

  test "exposes player profile and level progress" do
    assert_equal @user, @snapshot.user
    assert_equal gamification_profiles(:admin_profile), @snapshot.profile
    assert_equal 3, @snapshot.level_progress[:level]
    assert_equal "gamer/characters/rookie.png", @snapshot.character_asset_path
  end

  test "selects expected character tiers by level" do
    assert_equal "rookie", GamerDashboard::Snapshot.character_tier_for(1)
    assert_equal "scout", GamerDashboard::Snapshot.character_tier_for(5)
    assert_equal "adventurer", GamerDashboard::Snapshot.character_tier_for(10)
    assert_equal "specialist", GamerDashboard::Snapshot.character_tier_for(15)
    assert_equal "master", GamerDashboard::Snapshot.character_tier_for(20)
    assert_equal "grand_master", GamerDashboard::Snapshot.character_tier_for(25)
    assert_equal "sage", GamerDashboard::Snapshot.character_tier_for(30)
    assert_equal "sage_elite", GamerDashboard::Snapshot.character_tier_for(35)
    assert_equal "legend", GamerDashboard::Snapshot.character_tier_for(40)
    assert_equal "mythic_legend", GamerDashboard::Snapshot.character_tier_for(45)
    assert_equal "transcendent", GamerDashboard::Snapshot.character_tier_for(50)
  end

  test "tasks only include current user's active cards" do
    assert_equal todo_boards(:board_acme), @snapshot.todo_board
    assert_includes @snapshot.active_task_cards, todo_cards(:card_todo)
    assert_includes @snapshot.active_task_cards, todo_cards(:card_in_progress)
    refute_includes @snapshot.active_task_cards, todo_cards(:card_archived)
  end

  test "summaries include goals sports challenges and gamification" do
    assert @snapshot.goal_summary[:active_count] > 0
    assert @snapshot.sports_summary[:activities_this_week] > 0
    assert @snapshot.challenge_summary[:active_count] > 0
    assert @snapshot.recent_xp.respond_to?(:each)
    assert @snapshot.recent_achievements.respond_to?(:each)
  end

  test "quest feed has useful typed entries" do
    types = @snapshot.quest_feed.map { |quest| quest[:type] }

    assert_includes types, "task"
    assert_includes types, "habit"
    assert_includes types, "challenge"
    assert_includes types, "goal"
    assert_includes types, "training"
  end

  test "empty optional data still returns stable structures" do
    user = User.create!(
      email: "[email protected]",
      password: "password123",
      password_confirmation: "password123",
      confirmed_at: Time.current,
      finance_settings: {}
    )

    snapshot = GamerDashboard::Snapshot.new(user)

    assert_equal 0, snapshot.active_task_cards.size
    assert_equal 0, snapshot.goal_summary[:active_count]
    assert_equal 0, snapshot.sports_summary[:activities_this_week]
    assert_equal 0, snapshot.challenge_summary[:active_count]
    assert_equal "rookie", snapshot.character_tier
  end
end
  • Step 2: Run snapshot test to verify it fails

Run:

bin/rails test test/models/gamer_dashboard/snapshot_test.rb

Expected: FAIL because GamerDashboard::Snapshot does not exist.

  • Step 3: Implement the snapshot

Create app/models/gamer_dashboard/snapshot.rb:

class GamerDashboard::Snapshot
  MAP_ASSET_PATH = "gamer/maps/lifehub-world.png"

  CHARACTER_TIERS = {
    1..4 => "rookie",
    5..9 => "scout",
    10..14 => "adventurer",
    15..19 => "specialist",
    20..24 => "master",
    25..29 => "grand_master",
    30..34 => "sage",
    35..39 => "sage_elite",
    40..44 => "legend",
    45..49 => "mythic_legend",
    50..50 => "transcendent"
  }.freeze

  attr_reader :user

  def initialize(user)
    @user = user
  end

  def self.character_tier_for(level)
    CHARACTER_TIERS.find { |range, _tier| range.cover?(level.to_i.clamp(1, 50)) }&.last || "rookie"
  end

  def player_name
    user.finance_settings&.dig("display_name").presence ||
      user.email.split("@").first.tr("._-", " ").titleize
  end

  def profile
    @profile ||= Gamification::XpAwarder.find_or_create_profile(user)
  end

  def level_progress
    @level_progress ||= Gamification::LevelCalculator.progress(profile.total_xp)
  end

  def character_tier
    self.class.character_tier_for(level_progress[:level])
  end

  def character_asset_path
    "gamer/characters/#{character_tier}.png"
  end

  def map_asset_path = MAP_ASSET_PATH

  def finance_summary
    @finance_summary ||= begin
      kpis = finance_aggregator.kpis
      {
        total_balance_brl: kpis[:total_balance_brl],
        monthly_expenses: kpis[:monthly_expenses],
        investments_value: kpis[:investments_value],
        monthly_growth: kpis[:monthly_growth],
        goals_active: kpis[:goals_active],
        investments_pct_of_patrimony: kpis[:investments_pct_of_patrimony]
      }
    end
  end

  def habit_summary
    @habit_summary ||= begin
      streak = habit_aggregator.streak_summary
      {
        completed_today: habit_aggregator.todays_completed_count,
        total_today: habit_aggregator.todays_total_count,
        progress_percentage: habit_aggregator.todays_progress_percentage,
        longest_current: streak[:longest_current],
        longest_ever: streak[:longest_ever],
        at_risk: streak[:at_risk],
        incomplete: habit_aggregator.todays_habits.reject(&:completed_today?).first(5)
      }
    end
  end

  def todo_board
    @todo_board ||= TodoBoard.find_or_create_for(user)
  end

  def active_task_cards
    @active_task_cards ||= todo_board.todo_cards.active.ordered.to_a
  end

  def task_summary
    @task_summary ||= {
      active_count: active_task_cards.size,
      overdue_count: active_task_cards.count(&:overdue?),
      priority_count: active_task_cards.count { |card| card.high? || card.urgent? },
      by_column: active_task_cards.group_by(&:column_id)
    }
  end

  def active_goals
    @active_goals ||= user.goals.active.to_a.sort_by { |goal| -goal.progress_percentage }.first(5)
  end

  def goal_summary
    @goal_summary ||= {
      active_count: user.goals.active.count,
      financial_count: user.goals.active.where(tracking_mode: Goal::FINANCIAL_MODES).count,
      top_goals: active_goals
    }
  end

  def recent_activity_logs
    @recent_activity_logs ||= user.activity_logs.includes(:sport).order(date: :desc, created_at: :desc).limit(5).to_a
  end

  def sports_summary
    @sports_summary ||= begin
      logs_this_week = user.activity_logs.where(date: Date.current.beginning_of_week..Date.current)
      {
        activities_this_week: logs_this_week.count,
        minutes_this_week: logs_this_week.sum(:duration_minutes).to_i,
        recent_logs: recent_activity_logs,
        latest_sport: recent_activity_logs.first&.sport
      }
    end
  end

  def active_challenge_participations
    @active_challenge_participations ||= user.challenge_participations
      .active_participations
      .includes(:challenge)
      .recent
      .limit(4)
      .to_a
  end

  def challenge_summary
    @challenge_summary ||= {
      active_count: active_challenge_participations.size,
      completed_count: user.challenge_participations.completed_participations.count,
      active: active_challenge_participations
    }
  end

  def recent_achievements
    @recent_achievements ||= user.user_achievements.recent(6).includes(:achievement).to_a
  end

  def recent_xp
    @recent_xp ||= user.xp_transactions.recent(8).to_a
  end

  def quest_feed
    @quest_feed ||= [
      task_quests,
      habit_quests,
      challenge_quests,
      goal_quests,
      training_quests
    ].flatten.first(12)
  end

  private

  def finance_aggregator
    @finance_aggregator ||= Dashboard::DataAggregator.new(user)
  end

  def habit_aggregator
    @habit_aggregator ||= Habit::DashboardAggregator.new(user)
  end

  def task_quests
    active_task_cards.first(4).map do |card|
      { type: "task", title: card.title, subtitle: card.priority.titleize, record: card, progress: card.checklist_progress[:percentage] }
    end
  end

  def habit_quests
    habit_summary[:incomplete].map do |habit|
      { type: "habit", title: habit.name, subtitle: "#{habit.current_streak} day streak", record: habit, progress: habit.completion_rate(days: 30).round }
    end
  end

  def challenge_quests
    active_challenge_participations.map do |participation|
      { type: "challenge", title: participation.name, subtitle: "#{participation.progress}/#{participation.target_value}", record: participation, progress: participation.progress_percentage }
    end
  end

  def goal_quests
    active_goals.first(3).map do |goal|
      { type: "goal", title: goal.name, subtitle: "#{goal.progress_percentage}% complete", record: goal, progress: goal.progress_percentage }
    end
  end

  def training_quests
    recent_activity_logs.first(2).map do |log|
      minutes = log.duration_minutes.to_i
      { type: "training", title: log.sport&.name || log.activity_type.titleize, subtitle: "#{minutes} min", record: log, progress: [ minutes, 100 ].min }
    end
  end
end
  • Step 4: Run snapshot test

Run:

bin/rails test test/models/gamer_dashboard/snapshot_test.rb

Expected: PASS.

  • Step 5: Run controller test

Run:

bin/rails test test/controllers/gamer_dashboard_controller_test.rb

Expected: PASS.

  • Step 6: Commit snapshot

Run:

git add app/models/gamer_dashboard/snapshot.rb test/models/gamer_dashboard/snapshot_test.rb
git commit -m "feat: aggregate gamer dashboard data"

Expected: commit succeeds.


Task 4: Add Gamer Dashboard Helpers and Complete Translations

Files:

  • Create: app/helpers/gamer_dashboard_helper.rb
  • Modify: config/locales/en.yml
  • Modify: config/locales/pt-BR.yml

  • Step 1: Add helper tests through model/controller render coverage

Do not create a separate helper test unless view rendering exposes a helper bug. The controller test will render the full dashboard once Task 5 replaces the temporary view.

  • Step 2: Create the helper

Create app/helpers/gamer_dashboard_helper.rb:

module GamerDashboardHelper
  def gamer_quest_path(quest)
    record = quest[:record]

    case quest[:type]
    when "task" then todo_card_path(record)
    when "habit" then habit_path(record)
    when "challenge" then challenges_path
    when "goal" then goal_path(record)
    when "training" then sports_path
    else gamer_path
    end
  end

  def gamer_progress_width(value)
    [[ value.to_i, 0 ].max, 100 ].min
  end

  def gamer_type_color(type)
    {
      "task" => "#60a5fa",
      "habit" => "#a78bfa",
      "challenge" => "#f59e0b",
      "goal" => "#34d399",
      "training" => "#fb7185"
    }.fetch(type.to_s, "#94a3b8")
  end
end
  • Step 3: Expand English translations

Add under en::

  nav:
    gamer: Gamer

  gamer_dashboard:
    title: Gamer Dashboard
    subtitle: Your life as a playable command center.
    player: Player
    world: Lifehub World
    active_quests: Active Quests
    finance_vault: Finance Vault
    habit_forest: Habit Forest
    task_board: Task Board
    goal_temple: Goal Temple
    training_grounds: Training Grounds
    gamification: Progression
    achievements: Achievements
    challenges: Challenges
    next_reward: Next Reward
    level: "Lv. %{level}"
    total_xp: "%{xp} XP"
    xp_progress: "%{current} / %{needed} XP"
    no_quests: No active quests yet.
    no_achievements: No achievements unlocked yet.
    zones:
      tasks: Task Board
      habits: Habit Forest
      finance: Finance Vault
      goals: Goal Temple
      sports: Training Grounds
      challenges: Challenge Arena
      achievements: Achievement Hall
      levels: Level Shrine
    stats:
      patrimony: Patrimony
      monthly_expenses: Monthly Expenses
      investments: Investments
      monthly_growth: Monthly Growth
      completed_today: Completed Today
      streak: Streak
      at_risk: At Risk
      active_cards: Active Cards
      overdue: Overdue
      priority: Priority
      active_goals: Active Goals
      financial_goals: Financial Goals
      activities_week: Activities This Week
      minutes_week: Minutes This Week
      completed_challenges: Completed Challenges
  • Step 4: Expand Portuguese translations

Add under pt-BR::

  nav:
    gamer: Gamer

  gamer_dashboard:
    title: Painel Gamer
    subtitle: Sua vida como um centro de comando jogável.
    player: Jogador
    world: Mundo Lifehub
    active_quests: Missões Ativas
    finance_vault: Cofre Financeiro
    habit_forest: Floresta de Hábitos
    task_board: Quadro de Tarefas
    goal_temple: Templo de Metas
    training_grounds: Campo de Treino
    gamification: Progressão
    achievements: Conquistas
    challenges: Desafios
    next_reward: Próxima Recompensa
    level: "Nv. %{level}"
    total_xp: "%{xp} XP"
    xp_progress: "%{current} / %{needed} XP"
    no_quests: Nenhuma missão ativa ainda.
    no_achievements: Nenhuma conquista desbloqueada ainda.
    zones:
      tasks: Quadro de Tarefas
      habits: Floresta de Hábitos
      finance: Cofre Financeiro
      goals: Templo de Metas
      sports: Campo de Treino
      challenges: Arena de Desafios
      achievements: Salão de Conquistas
      levels: Santuário de Nível
    stats:
      patrimony: Patrimônio
      monthly_expenses: Gastos do Mês
      investments: Investimentos
      monthly_growth: Crescimento Mensal
      completed_today: Concluídos Hoje
      streak: Sequência
      at_risk: Em Risco
      active_cards: Cards Ativos
      overdue: Atrasados
      priority: Prioridade
      active_goals: Metas Ativas
      financial_goals: Metas Financeiras
      activities_week: Atividades na Semana
      minutes_week: Minutos na Semana
      completed_challenges: Desafios Concluídos
  • Step 5: Run tests

Run:

bin/rails test test/controllers/gamer_dashboard_controller_test.rb test/models/gamer_dashboard/snapshot_test.rb

Expected: PASS.

  • Step 6: Commit helper and translations

Run:

git add app/helpers/gamer_dashboard_helper.rb config/locales/en.yml config/locales/pt-BR.yml
git commit -m "feat: add gamer dashboard helpers and copy"

Expected: commit succeeds.


Task 5: Build the Full Gamer Layout and Partials

Files:

  • Modify: app/views/gamer_dashboard/show.html.erb
  • Create: app/views/gamer_dashboard/_player_panel.html.erb
  • Create: app/views/gamer_dashboard/_world_map.html.erb
  • Create: app/views/gamer_dashboard/_quest_feed.html.erb
  • Create: app/views/gamer_dashboard/_finance_panel.html.erb
  • Create: app/views/gamer_dashboard/_habits_panel.html.erb
  • Create: app/views/gamer_dashboard/_tasks_panel.html.erb
  • Create: app/views/gamer_dashboard/_goals_panel.html.erb
  • Create: app/views/gamer_dashboard/_sports_panel.html.erb
  • Create: app/views/gamer_dashboard/_gamification_panel.html.erb

  • Step 1: Replace the temporary show view

Use this structure in app/views/gamer_dashboard/show.html.erb:

<% content_for :page_full_width, true %>

<div class="gamer-dashboard min-h-[calc(100dvh-7rem)]">
  <div class="mb-4 flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
    <div>
      <h1 class="text-[22px] font-black text-white"><%= t("gamer_dashboard.title") %></h1>
      <p class="text-[12px] text-slate-400"><%= t("gamer_dashboard.subtitle") %></p>
    </div>
    <%= link_to gamification_path, class: "gamer-top-level inline-flex items-center gap-2" do %>
      <%= image_tag @snapshot.character_asset_path, class: "size-8 image-render-pixel", alt: "" %>
      <span><%= t("gamer_dashboard.level", level: @snapshot.level_progress[:level]) %></span>
    <% end %>
  </div>

  <div class="grid grid-cols-1 gap-3 xl:grid-cols-[280px_minmax(0,1fr)_320px]">
    <aside class="space-y-3">
      <%= render "player_panel", snapshot: @snapshot %>
      <%= render "habits_panel", snapshot: @snapshot %>
    </aside>

    <section class="space-y-3 min-w-0">
      <%= render "world_map", snapshot: @snapshot %>
      <div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
        <%= render "quest_feed", snapshot: @snapshot %>
        <%= render "tasks_panel", snapshot: @snapshot %>
      </div>
      <div class="grid grid-cols-1 gap-3 lg:grid-cols-2">
        <%= render "goals_panel", snapshot: @snapshot %>
        <%= render "sports_panel", snapshot: @snapshot %>
      </div>
    </section>

    <aside class="space-y-3">
      <%= render "finance_panel", snapshot: @snapshot %>
      <%= render "gamification_panel", snapshot: @snapshot %>
    </aside>
  </div>
</div>
  • Step 2: Create the player panel partial

Create app/views/gamer_dashboard/_player_panel.html.erb with:

<div class="gamer-panel p-4">
  <div class="flex items-center gap-4">
    <div class="gamer-avatar-frame">
      <%= image_tag snapshot.character_asset_path, class: "size-24 image-render-pixel", alt: "" %>
    </div>
    <div class="min-w-0 flex-1">
      <p class="text-[11px] font-bold uppercase text-slate-500"><%= t("gamer_dashboard.player") %></p>
      <h2 class="truncate text-[18px] font-black text-white"><%= snapshot.player_name %></h2>
      <p class="text-[12px] text-slate-400"><%= snapshot.level_progress[:title] %></p>
    </div>
  </div>

  <div class="mt-4">
    <div class="mb-1 flex items-center justify-between text-[11px] text-slate-400">
      <span><%= t("gamer_dashboard.level", level: snapshot.level_progress[:level]) %></span>
      <span><%= t("gamer_dashboard.total_xp", xp: number_with_delimiter(snapshot.level_progress[:total_xp])) %></span>
    </div>
    <div class="h-2 overflow-hidden rounded bg-slate-800">
      <div class="h-full rounded bg-gradient-to-r from-pink-400 via-amber-300 to-emerald-300" style="width: <%= gamer_progress_width(snapshot.level_progress[:percentage]) %>%"></div>
    </div>
    <p class="mt-1 text-right text-[10px] text-slate-500">
      <%= t("gamer_dashboard.xp_progress", current: number_with_delimiter(snapshot.level_progress[:xp_in_level]), needed: number_with_delimiter(snapshot.level_progress[:xp_needed])) %>
    </p>
  </div>

  <div class="mt-4 grid grid-cols-3 gap-2 text-center">
    <div class="gamer-mini-stat">
      <strong><%= snapshot.recent_achievements.size %></strong>
      <span><%= t("gamer_dashboard.achievements") %></span>
    </div>
    <div class="gamer-mini-stat">
      <strong><%= snapshot.challenge_summary[:active_count] %></strong>
      <span><%= t("gamer_dashboard.challenges") %></span>
    </div>
    <div class="gamer-mini-stat">
      <strong><%= snapshot.habit_summary[:completed_today] %>/<%= snapshot.habit_summary[:total_today] %></strong>
      <span><%= t("gamer_dashboard.stats.completed_today") %></span>
    </div>
  </div>
</div>
  • Step 3: Create the world map partial

Create app/views/gamer_dashboard/_world_map.html.erb with:

<div class="gamer-panel overflow-hidden">
  <div class="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
    <h2 class="text-[15px] font-black text-white"><%= t("gamer_dashboard.world") %></h2>
    <span class="rounded border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-bold text-emerald-300">Lifehub</span>
  </div>
  <div class="gamer-map" style="background-image: url('<%= image_path(snapshot.map_asset_path) %>')">
    <%= link_to todo_board_path, class: "gamer-map-zone left-[11%] top-[21%]" do %><span><%= t("gamer_dashboard.zones.tasks") %></span><small><%= snapshot.task_summary[:active_count] %></small><% end %>
    <%= link_to habits_path, class: "gamer-map-zone left-[34%] top-[18%]" do %><span><%= t("gamer_dashboard.zones.habits") %></span><small><%= snapshot.habit_summary[:progress_percentage] %>%</small><% end %>
    <%= link_to finance_dashboard_path, class: "gamer-map-zone right-[15%] top-[18%]" do %><span><%= t("gamer_dashboard.zones.finance") %></span><small><%= number_to_percentage(snapshot.finance_summary[:investments_pct_of_patrimony], precision: 0) %></small><% end %>
    <%= link_to goals_path, class: "gamer-map-zone left-[42%] top-[48%]" do %><span><%= t("gamer_dashboard.zones.goals") %></span><small><%= snapshot.goal_summary[:active_count] %></small><% end %>
    <%= link_to sports_path, class: "gamer-map-zone left-[17%] bottom-[16%]" do %><span><%= t("gamer_dashboard.zones.sports") %></span><small><%= snapshot.sports_summary[:activities_this_week] %></small><% end %>
    <%= link_to challenges_path, class: "gamer-map-zone right-[12%] bottom-[20%]" do %><span><%= t("gamer_dashboard.zones.challenges") %></span><small><%= snapshot.challenge_summary[:active_count] %></small><% end %>
    <%= link_to gamification_achievements_path, class: "gamer-map-zone left-[59%] top-[14%]" do %><span><%= t("gamer_dashboard.zones.achievements") %></span><small><%= snapshot.recent_achievements.size %></small><% end %>
    <%= link_to gamification_path, class: "gamer-map-zone right-[30%] bottom-[38%]" do %><span><%= t("gamer_dashboard.zones.levels") %></span><small><%= t("gamer_dashboard.level", level: snapshot.level_progress[:level]) %></small><% end %>
  </div>
</div>
  • Step 4: Create compact panel partials

Build each remaining partial with the snapshot reader matching its filename:

<%# app/views/gamer_dashboard/_quest_feed.html.erb %>
<div class="gamer-panel p-4">
  <div class="mb-3 flex items-center justify-between">
    <h2 class="text-[14px] font-black text-white"><%= t("gamer_dashboard.active_quests") %></h2>
  </div>
  <div class="space-y-2">
    <% if snapshot.quest_feed.any? %>
      <% snapshot.quest_feed.each do |quest| %>
        <%= link_to gamer_quest_path(quest), class: "gamer-row" do %>
          <span class="gamer-chip" style="color: <%= gamer_type_color(quest[:type]) %>; border-color: <%= gamer_type_color(quest[:type]) %>55;"><%= quest[:type].titleize %></span>
          <span class="min-w-0 flex-1">
            <strong class="block truncate text-[12px] text-white"><%= quest[:title] %></strong>
            <small class="block truncate text-[10px] text-slate-500"><%= quest[:subtitle] %></small>
          </span>
          <span class="w-16 overflow-hidden rounded bg-slate-800">
            <span class="block h-1.5 rounded bg-cyan-300" style="width: <%= gamer_progress_width(quest[:progress]) %>%"></span>
          </span>
        <% end %>
      <% end %>
    <% else %>
      <p class="py-8 text-center text-[12px] text-slate-500"><%= t("gamer_dashboard.no_quests") %></p>
    <% end %>
  </div>
</div>

Use the same gamer-panel, gamer-row, and gamer-chip pattern for finance, habits, tasks, goals, sports, and gamification. Each partial must render only fields already exposed by GamerDashboard::Snapshot.

  • Step 5: Run render test

Run:

bin/rails test test/controllers/gamer_dashboard_controller_test.rb

Expected: PASS and the authenticated response contains Gamer Dashboard.

  • Step 6: Commit views

Run:

git add app/views/gamer_dashboard
git commit -m "feat: render gamer dashboard layout"

Expected: commit succeeds.


Task 6: Add Scoped Pixel Dashboard CSS

Files:

  • Modify: app/assets/tailwind/application.css

  • Step 1: Add CSS at the bottom of the Tailwind application stylesheet

Append to app/assets/tailwind/application.css:

.gamer-dashboard {
  --gamer-panel: rgba(10, 16, 28, 0.88);
  --gamer-border: rgba(148, 163, 184, 0.18);
  --gamer-glow: rgba(96, 165, 250, 0.18);
  color: #e5e7eb;
}

.gamer-panel {
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.012)),
    var(--gamer-panel);
  border: 1px solid var(--gamer-border);
  border-radius: 8px;
  box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.8), 0 16px 48px rgba(0, 0, 0, 0.28);
}

.gamer-top-level {
  border: 1px solid var(--gamer-border);
  border-radius: 8px;
  background: rgba(15, 23, 42, 0.9);
  padding: 0.35rem 0.55rem;
  font-size: 0.75rem;
  font-weight: 800;
  color: #f8fafc;
}

.gamer-avatar-frame {
  border: 1px solid rgba(248, 113, 113, 0.35);
  border-radius: 8px;
  background: radial-gradient(circle at 50% 38%, rgba(251, 191, 36, 0.28), rgba(15, 23, 42, 0.92) 62%);
  padding: 0.5rem;
}

.image-render-pixel {
  image-rendering: pixelated;
  image-rendering: crisp-edges;
}

.gamer-mini-stat {
  border: 1px solid rgba(148, 163, 184, 0.14);
  border-radius: 6px;
  background: rgba(15, 23, 42, 0.55);
  padding: 0.55rem 0.35rem;
}

.gamer-mini-stat strong {
  display: block;
  font-size: 0.8rem;
  color: #f8fafc;
}

.gamer-mini-stat span {
  display: block;
  margin-top: 0.1rem;
  font-size: 0.62rem;
  color: #94a3b8;
}

.gamer-map {
  position: relative;
  min-height: 440px;
  background-size: cover;
  background-position: center;
  isolation: isolate;
}

.gamer-map::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px),
    radial-gradient(circle at 50% 50%, transparent 45%, rgba(2, 6, 23, 0.22));
  background-size: 8px 8px, 8px 8px, cover;
}

.gamer-map-zone {
  position: absolute;
  z-index: 1;
  max-width: 150px;
  border: 1px solid rgba(226, 232, 240, 0.2);
  border-radius: 6px;
  background: rgba(2, 6, 23, 0.72);
  padding: 0.45rem 0.55rem;
  color: #f8fafc;
  backdrop-filter: blur(4px);
  transition: transform 150ms ease, border-color 150ms ease, background 150ms ease;
}

.gamer-map-zone:hover {
  transform: translateY(-2px);
  border-color: rgba(125, 211, 252, 0.55);
  background: rgba(15, 23, 42, 0.86);
}

.gamer-map-zone span,
.gamer-map-zone small {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.gamer-map-zone span {
  font-size: 0.72rem;
  font-weight: 800;
}

.gamer-map-zone small {
  margin-top: 0.1rem;
  font-size: 0.62rem;
  color: #94a3b8;
}

.gamer-row {
  display: flex;
  min-height: 44px;
  align-items: center;
  gap: 0.6rem;
  border: 1px solid rgba(148, 163, 184, 0.1);
  border-radius: 6px;
  background: rgba(15, 23, 42, 0.45);
  padding: 0.45rem 0.55rem;
  transition: background 150ms ease, border-color 150ms ease;
}

.gamer-row:hover {
  border-color: rgba(125, 211, 252, 0.28);
  background: rgba(30, 41, 59, 0.55);
}

.gamer-chip {
  flex: 0 0 auto;
  border: 1px solid currentColor;
  border-radius: 4px;
  padding: 0.12rem 0.32rem;
  font-size: 0.58rem;
  font-weight: 900;
  line-height: 1;
  text-transform: uppercase;
}

@media (max-width: 640px) {
  .gamer-map {
    min-height: 360px;
  }

  .gamer-map-zone {
    max-width: 118px;
    padding: 0.35rem 0.45rem;
  }
}
  • Step 2: Run controller render test

Run:

bin/rails test test/controllers/gamer_dashboard_controller_test.rb

Expected: PASS.

  • Step 3: Commit CSS

Run:

git add app/assets/tailwind/application.css
git commit -m "style: add gamer dashboard pixel styling"

Expected: commit succeeds.


Task 7: Add Navigation Entry and Icon

Files:

  • Modify: app/helpers/application_helper.rb
  • Modify: app/views/shared/_sidebar.html.erb
  • Modify: app/views/shared/_navbar.html.erb
  • Modify: app/views/shared/_footer_nav.html.erb

  • Step 1: Add a gamer icon case

In ApplicationHelper#sidebar_icon, add this case near the gamification case:

when "gamer"
  content_tag(:svg, class: cls, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
    content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "1.8", d: "M6 8h12v8H6V8z") +
      content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "1.8", d: "M9 8V5h6v3M9 16v3h6v-3M8.5 12h.01M15.5 12h.01")
  end

In ApplicationHelper#nav_link icon string handling, add this case near the gamification case:

when "gamer"
  content_tag(:svg, class: icon_class, fill: "none", stroke: "currentColor", viewBox: "0 0 24 24") do
    content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M6 8h12v8H6V8z") +
      content_tag(:path, nil, "stroke-linecap": "round", "stroke-linejoin": "round", "stroke-width": "2", d: "M9 8V5h6v3M9 16v3h6v-3M8.5 12h.01M15.5 12h.01")
  end
  • Step 2: Add sidebar link

In app/views/shared/_sidebar.html.erb, inside the gamification section before the existing gamification link, add:

<%= navbar_nav_item t("nav.gamer"), gamer_path, icon: "gamer" %>
  • Step 3: Add navbar link

In app/views/shared/_navbar.html.erb, add a Gamer item near the current main dashboard or gamification navigation:

<%= navbar_nav_item t("nav.gamer"), gamer_path, icon: "gamer" %>
  • Step 4: Add mobile More drawer link

In app/views/shared/_footer_nav.html.erb, inside the gamification section before the existing gamification link, add:

<%= more_drawer_item t("nav.gamer"), gamer_path, icon: "gamer" %>
  • Step 5: Run controller test

Run:

bin/rails test test/controllers/gamer_dashboard_controller_test.rb

Expected: PASS.

  • Step 6: Commit navigation

Run:

git add app/helpers/application_helper.rb app/views/shared/_sidebar.html.erb app/views/shared/_navbar.html.erb app/views/shared/_footer_nav.html.erb
git commit -m "feat: add gamer dashboard navigation"

Expected: commit succeeds.


Task 8: Strengthen View Rendering Tests

Files:

  • Modify: test/controllers/gamer_dashboard_controller_test.rb

  • Step 1: Add assertions for key sections

Update the authenticated test:

test "authenticated user can access gamer dashboard" do
  sign_in users(:admin)

  get gamer_url

  assert_response :success
  assert_select "h1", I18n.t("gamer_dashboard.title")
  assert_select ".gamer-map"
  assert_select ".gamer-panel", minimum: 6
  assert_includes response.body, I18n.t("gamer_dashboard.finance_vault")
  assert_includes response.body, I18n.t("gamer_dashboard.goal_temple")
  assert_includes response.body, I18n.t("gamer_dashboard.training_grounds")
end
  • Step 2: Run controller test

Run:

bin/rails test test/controllers/gamer_dashboard_controller_test.rb

Expected: PASS.

  • Step 3: Commit test hardening

Run:

git add test/controllers/gamer_dashboard_controller_test.rb
git commit -m "test: cover gamer dashboard sections"

Expected: commit succeeds.


Task 9: Manual Browser Verification

Files:

  • No code changes expected unless verification reveals layout defects.

  • Step 1: Start Rails server

Run:

bin/rails server -p 3000

Expected: server starts on port 3000. If port 3000 is occupied, use port 3001.

  • Step 2: Open /gamer in browser

Use the browser at:

http://localhost:3000/gamer

Expected after signing in:

  • Page loads without errors.
  • Pixel map renders.
  • Character sprite renders.
  • Map labels do not overlap unreadably on desktop.
  • Panels stack cleanly on mobile width.
  • Money values respect hide-balances behavior if the existing global controller masks .balance-value.

  • Step 3: Fix any layout defects with scoped CSS

If text overflows, adjust only .gamer-* CSS classes in app/assets/tailwind/application.css. Do not refactor unrelated layout files.

  • Step 4: Stop server

Stop the Rails server with Ctrl-C.


Task 10: Final Verification

Files:

  • No code changes expected unless tests fail.

  • Step 1: Run targeted tests

Run:

bin/rails test test/controllers/gamer_dashboard_controller_test.rb test/models/gamer_dashboard/snapshot_test.rb

Expected: PASS.

  • Step 2: Run full CI

Run:

bin/ci

Expected: PASS.

  • Step 3: Commit final fixes if any were required

If verification required fixes, run:

git add app test config
git commit -m "fix: polish gamer dashboard"

Expected: either no changes remain, or one final fix commit exists.


Implementation Status (2026-05-06)

Tasks 1–11 shipped in commits af25f94 feat: add gamer dashboard snapshot and 91135bf gamer dashboard WIP. Notable deltas from the original plan that landed during execution:

  • The route lives inside the authenticated :user do block (signed-out users redirect via Devise). The original plan had it at top level — the in-tree route is correct and is what this revision documents.
  • Pixel character PNGs were generated and then re-cropped to square shapes (some had neighboring-cell bleed from the sprite sheet). Final files live at app/assets/images/gamer/characters/<tier>.png.
  • The Habit Forest panel ships with inline toggle buttons (button_to toggle_habit_path(habit, return_to: gamer_path)) so users can complete habits directly from /gamer — this was a runtime addition not in the original plan and is now part of the contract.
  • The route-routing test was simplified to a path-helper assertion (assert_equal "/gamer", gamer_path) because assert_routing collides with the authenticated route constraint.

Task 12 below is the new work covered by this revision. It replaces the four center-column panels with hover cards on the map, adds Weekly Activity + Achievements Row in the left column, and extends the snapshot accordingly.


Task 12: Hover-Driven Map Zones & Layout Restructure

Files:

  • Modify: app/models/gamer_dashboard/snapshot.rb
  • Modify: app/views/gamer_dashboard/show.html.erb
  • Modify: app/views/gamer_dashboard/_world_map.html.erb
  • Create: app/views/gamer_dashboard/_zone.html.erb
  • Create: app/views/gamer_dashboard/_weekly_activity.html.erb
  • Create: app/views/gamer_dashboard/_achievements_row.html.erb
  • Create: app/views/gamer_dashboard/_zone_preview/_tasks.html.erb
  • Create: app/views/gamer_dashboard/_zone_preview/_habits.html.erb
  • Create: app/views/gamer_dashboard/_zone_preview/_finance.html.erb
  • Create: app/views/gamer_dashboard/_zone_preview/_goals.html.erb
  • Create: app/views/gamer_dashboard/_zone_preview/_sports.html.erb
  • Create: app/views/gamer_dashboard/_zone_preview/_challenges.html.erb
  • Create: app/views/gamer_dashboard/_zone_preview/_achievements.html.erb
  • Create: app/views/gamer_dashboard/_zone_preview/_levels.html.erb
  • Create: app/javascript/controllers/gamer_map_zone_controller.js
  • Modify: app/helpers/gamer_dashboard_helper.rb
  • Modify: app/assets/tailwind/application.css (zone hover-card CSS)
  • Delete: app/views/gamer_dashboard/_quest_feed.html.erb
  • Delete: app/views/gamer_dashboard/_tasks_panel.html.erb
  • Delete: app/views/gamer_dashboard/_goals_panel.html.erb
  • Delete: app/views/gamer_dashboard/_sports_panel.html.erb
  • Modify: config/locales/en.yml, config/locales/pt-BR.yml
  • Modify: test/models/gamer_dashboard/snapshot_test.rb
  • Modify: test/controllers/gamer_dashboard_controller_test.rb

Cross-cutting principles

  • DRY: every zone goes through the single _zone.html.erb partial which renders a _zone_preview/<slug>.html.erb based on the zone slug. No per-zone copy-paste in the world map.
  • Performance: all preview data is loaded by GamerDashboard::Snapshot in the same pass as the existing summaries — no extra controller queries. Preview lists are capped at 4 records and use .includes where they cross associations.
  • Security: every preview reader scopes through current_user-owned associations (user.todo_cards.active, user.habits, user.goals.active, user.activity_logs, user.challenge_participations.joins(:challenge), user.user_achievements.joins(:achievement)). No record from another user can leak through a hover card.

Steps

  • Step 1: Failing snapshot tests for the new readers

In test/models/gamer_dashboard/snapshot_test.rb, add:

test "weekly_activity returns this week's totals" do
  weekly = @snapshot.weekly_activity
  %i[habit_completions tasks_completed activity_logs activity_minutes xp_earned].each do |key|
    assert weekly.key?(key), "missing #{key}"
    assert_kind_of Integer, weekly[key]
  end
end

test "weekly_xp_by_day returns 7 buckets in chronological order" do
  series = @snapshot.weekly_xp_by_day
  assert_equal 7, series.size
  assert series.all? { |day, xp| day.is_a?(Date) && xp.is_a?(Integer) }
  assert_equal series.map(&:first).sort, series.map(&:first)
end

test "top_difficulty_achievements orders by rarity desc, requirement_value desc" do
  result = @snapshot.top_difficulty_achievements(limit: 6)
  assert_operator result.size, :<=, 6
  rarities = result.map { |ua| ua.achievement.rarity }
  rank = ->(r) { %w[legendary epic rare uncommon common].index(r) }
  assert_equal rarities, rarities.sort_by { |r| rank.call(r) }
end

test "zone_preview returns at most four pre-shaped items per zone" do
  %i[tasks habits finance goals sports challenges achievements levels].each do |slug|
    items = @snapshot.zone_preview(slug)
    assert_kind_of Array, items, "#{slug} preview should be an array"
    assert_operator items.size, :<=, 4, "#{slug} preview exceeds 4 items"
  end
end

test "tasks zone_preview only includes current user's active todo cards" do
  preview = @snapshot.zone_preview(:tasks)
  preview.each { |item| assert_equal @user.id, item[:record].todo_board.user_id }
end

Run:

bin/rails test test/models/gamer_dashboard/snapshot_test.rb

Expected: FAIL.

  • Step 2: Extend GamerDashboard::Snapshot with weekly + zone preview readers

Add to app/models/gamer_dashboard/snapshot.rb:

RARITY_ORDER = %w[legendary epic rare uncommon common].freeze

def weekly_activity
  @weekly_activity ||= {
    habit_completions: weekly_habit_completions,
    tasks_completed:   weekly_tasks_completed,
    activity_logs:     weekly_activity_logs.size,
    activity_minutes:  weekly_activity_logs.sum { |log| log.duration_minutes.to_i },
    xp_earned:         weekly_xp_total
  }
end

def weekly_xp_earned = weekly_xp_total

def weekly_xp_by_day
  @weekly_xp_by_day ||= begin
    sums = user.xp_transactions
               .where(created_at: week_range)
               .group("date(created_at)")
               .sum(:amount)
    (week_start..week_end).map { |date| [date, sums[date.to_s].to_i] }
  end
end

def top_difficulty_achievements(limit: 6)
  @top_difficulty_achievements ||= user.user_achievements
    .joins(:achievement)
    .includes(:achievement)
    .order(Arel.sql(<<~SQL), Arel.sql("achievements.requirement_value DESC"), unlocked_at: :desc)
      CASE achievements.rarity
        WHEN 'legendary' THEN 0
        WHEN 'epic'      THEN 1
        WHEN 'rare'      THEN 2
        WHEN 'uncommon'  THEN 3
        ELSE 4
      END
    SQL
    .limit(limit).to_a
end

def zone_preview(slug)
  case slug.to_sym
  when :tasks        then tasks_zone_preview
  when :habits       then habits_zone_preview
  when :finance      then finance_zone_preview
  when :goals        then goals_zone_preview
  when :sports       then sports_zone_preview
  when :challenges   then challenges_zone_preview
  when :achievements then achievements_zone_preview
  when :levels       then levels_zone_preview
  else []
  end
end

private

def week_start = @week_start ||= Time.zone.today.beginning_of_week
def week_end   = @week_end   ||= Time.zone.today.end_of_week
def week_range = week_start.beginning_of_day..week_end.end_of_day

def weekly_habit_completions
  user.habits.joins(:habit_completions).where(habit_completions: { completed_on: week_start..week_end }).count
end

def weekly_tasks_completed
  todo_board.todo_cards.where(completed_at: week_range).count
end

def weekly_activity_logs
  @weekly_activity_logs ||= user.activity_logs.where(performed_on: week_start..week_end).to_a
end

def weekly_xp_total
  user.xp_transactions.where(created_at: week_range).sum(:amount)
end

def tasks_zone_preview
  active_task_cards.first(4).map do |card|
    { record: card, title: card.title, badge: card.priority, href: todo_card_path(card) }
  end
end

def habits_zone_preview
  habit_summary[:todays_habits].first(4).map do |habit|
    { record: habit, title: habit.name, done: habit.completed_today?, href: habit_path(habit) }
  end
end

def finance_zone_preview
  k = finance_summary
  [
    { label: I18n.t("gamer_dashboard.zones.previews.finance.patrimony"), value: helpers.number_to_currency(k[:total_balance_brl]) },
    { label: I18n.t("gamer_dashboard.zones.previews.finance.expenses"),  value: helpers.number_to_currency(k[:monthly_expenses]) },
    { label: I18n.t("gamer_dashboard.zones.previews.finance.investments_pct"), value: "#{k[:investments_pct_of_patrimony].to_i}%" },
    { label: I18n.t("gamer_dashboard.zones.previews.finance.growth"),    value: "#{k[:monthly_growth].to_i}%" }
  ]
end

def goals_zone_preview
  active_goals.first(4).map do |goal|
    { record: goal, title: goal.name, percentage: goal.progress_percentage.to_i, href: goal_path(goal) }
  end
end

def sports_zone_preview
  weekly_activity_logs.first(4).map do |log|
    { record: log, title: log.sport&.name || log.title, minutes: log.duration_minutes.to_i, href: sports_path }
  end
end

def challenges_zone_preview
  user.challenge_participations.active.includes(:challenge).first(4).map do |part|
    { record: part, title: part.challenge.name, progress: part.progress_percentage.to_i, href: challenges_path }
  end
end

def achievements_zone_preview
  user.user_achievements.recent(4).includes(:achievement).map do |ua|
    { record: ua, title: ua.name, icon: ua.icon, rarity: ua.rarity, href: gamification_achievements_path }
  end
end

def levels_zone_preview
  prog = level_progress
  recent_xp.first(4).map do |tx|
    { label: tx.description, value: "+#{tx.amount}", href: gamification_path }
  end.tap do |list|
    list.unshift({ label: I18n.t("gamer_dashboard.zones.previews.levels.current"),
                   value: prog[:title], href: gamification_path })
    list.first(4)
  end
end

def helpers = ActionController::Base.helpers

Run:

bin/rails test test/models/gamer_dashboard/snapshot_test.rb

Expected: GREEN.

  • Step 3: Build the shared _zone.html.erb partial

app/views/gamer_dashboard/_zone.html.erb:

<%# locals: slug:, href:, label:, stat:, position:, snapshot: %>
<div class="gamer-zone <%= position %>"
     data-controller="gamer-map-zone"
     data-action="click->gamer-map-zone#toggle keydown.esc@window->gamer-map-zone#close click@window->gamer-map-zone#closeIfOutside">
  <%= link_to href,
              class: "gamer-map-zone block",
              data: { gamer_map_zone_target: "trigger" } do %>
    <span><%= label %></span>
    <small><%= stat %></small>
  <% end %>

  <div class="gamer-zone-card"
       data-gamer-map-zone-target="card"
       role="region"
       aria-label="<%= label %>">
    <%= link_to href, class: "gamer-zone-card-link block" do %>
      <h3 class="gamer-zone-card-title"><%= label %></h3>
      <%= render "gamer_dashboard/zone_preview/#{slug}", items: snapshot.zone_preview(slug) %>
      <p class="gamer-zone-card-cta"><%= t("gamer_dashboard.zones.open") %> →</p>
    <% end %>
  </div>
</div>
  • Step 4: Build the eight _zone_preview partials

Each partial reads items (an array of pre-shaped hashes from the snapshot) and renders rows. They are intentionally thin and uniform. Examples:

_zone_preview/_tasks.html.erb:

<%# locals: items: %>
<ul class="gamer-zone-card-list">
  <% items.each do |item| %>
    <li class="gamer-zone-card-row">
      <span class="truncate"><%= item[:title] %></span>
      <span class="gamer-zone-card-chip"><%= t("todo_board.priority.#{item[:badge]}", default: item[:badge]) %></span>
    </li>
  <% end %>
  <% if items.empty? %>
    <li class="gamer-zone-card-empty"><%= t("gamer_dashboard.zones.previews.empty") %></li>
  <% end %>
</ul>

_zone_preview/_habits.html.erb:

<ul class="gamer-zone-card-list">
  <% items.each do |item| %>
    <li class="gamer-zone-card-row">
      <span class="truncate"><%= item[:title] %></span>
      <span class="<%= item[:done] ? 'text-emerald-300' : 'text-slate-500' %>">
        <%= item[:done] ? "✓" : "○" %>
      </span>
    </li>
  <% end %>
</ul>

_zone_preview/_finance.html.erb:

<dl class="gamer-zone-card-grid">
  <% items.each do |item| %>
    <div class="gamer-zone-card-stat">
      <dt><%= item[:label] %></dt>
      <dd><%= item[:value] %></dd>
    </div>
  <% end %>
</dl>

_zone_preview/_goals.html.erb:

<ul class="gamer-zone-card-list">
  <% items.each do |item| %>
    <li class="gamer-zone-card-row">
      <span class="truncate"><%= item[:title] %></span>
      <span class="gamer-zone-card-pct"><%= item[:percentage] %>%</span>
    </li>
  <% end %>
</ul>

_zone_preview/_sports.html.erb:

<ul class="gamer-zone-card-list">
  <% items.each do |item| %>
    <li class="gamer-zone-card-row">
      <span class="truncate"><%= item[:title] %></span>
      <span class="text-slate-400"><%= item[:minutes] %>m</span>
    </li>
  <% end %>
</ul>

_zone_preview/_challenges.html.erb:

<ul class="gamer-zone-card-list">
  <% items.each do |item| %>
    <li class="gamer-zone-card-row">
      <span class="truncate"><%= item[:title] %></span>
      <span class="gamer-zone-card-pct"><%= item[:progress] %>%</span>
    </li>
  <% end %>
</ul>

_zone_preview/_achievements.html.erb:

<ul class="gamer-zone-card-icons">
  <% items.each do |item| %>
    <li title="<%= item[:title] %>" class="gamer-achievement-tile gamer-achievement-tile-#{item[:rarity]}">
      <span class="gamer-achievement-icon"><%= item[:icon] %></span>
    </li>
  <% end %>
</ul>

_zone_preview/_levels.html.erb:

<ul class="gamer-zone-card-list">
  <% items.each do |item| %>
    <li class="gamer-zone-card-row">
      <span class="truncate"><%= item[:label] %></span>
      <span class="text-amber-300"><%= item[:value] %></span>
    </li>
  <% end %>
</ul>
  • Step 5: Rebuild _world_map.html.erb to use _zone.html.erb

Replace the current inline 8-link block with calls to the shared partial. Position strings (left-[11%] top-[21%], etc.) carry over unchanged from the shipped version. Keep the map background-image attribute and aspect-ratio styling.

<div class="gamer-panel overflow-hidden">
  <div class="flex items-center justify-between border-b border-white/[0.06] px-4 py-3">
    <h2 class="text-[15px] font-black text-white"><%= t("gamer_dashboard.world") %></h2>
    <span class="rounded border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-bold text-emerald-300">Lifehub</span>
  </div>
  <div class="gamer-map" style="background-image: url('<%= image_path(snapshot.map_asset_path) %>')">
    <%= render "zone", slug: :tasks,        href: todo_board_path,                 label: t("gamer_dashboard.zones.tasks"),        stat: snapshot.task_summary[:active_count],                                       position: "left-[11%] top-[21%]",   snapshot: snapshot %>
    <%= render "zone", slug: :habits,       href: habits_path,                     label: t("gamer_dashboard.zones.habits"),       stat: "#{snapshot.habit_summary[:progress_percentage]}%",                          position: "left-[34%] top-[18%]",   snapshot: snapshot %>
    <%= render "zone", slug: :achievements, href: gamification_achievements_path,  label: t("gamer_dashboard.zones.achievements"), stat: snapshot.achievement_summary[:unlocked_count],                                position: "left-[59%] top-[14%]",   snapshot: snapshot %>
    <%= render "zone", slug: :finance,      href: finance_dashboard_path,          label: t("gamer_dashboard.zones.finance"),      stat: number_to_percentage(snapshot.finance_summary[:investments_pct_of_patrimony], precision: 0), position: "right-[15%] top-[18%]", snapshot: snapshot %>
    <%= render "zone", slug: :goals,        href: goals_path,                      label: t("gamer_dashboard.zones.goals"),        stat: snapshot.goal_summary[:active_count],                                        position: "left-[42%] top-[48%]",   snapshot: snapshot %>
    <%= render "zone", slug: :levels,       href: gamification_path,               label: t("gamer_dashboard.zones.levels"),       stat: t("gamer_dashboard.level", level: snapshot.level_progress[:level]),          position: "right-[30%] bottom-[38%]", snapshot: snapshot %>
    <%= render "zone", slug: :sports,       href: sports_path,                     label: t("gamer_dashboard.zones.sports"),       stat: snapshot.sports_summary[:activities_this_week],                              position: "left-[17%] bottom-[16%]", snapshot: snapshot %>
    <%= render "zone", slug: :challenges,   href: challenges_path,                 label: t("gamer_dashboard.zones.challenges"),   stat: snapshot.challenge_summary[:active_count],                                   position: "right-[12%] bottom-[20%]", snapshot: snapshot %>
  </div>
</div>
  • Step 6: CSS — hover card positioning

Append to app/assets/tailwind/application.css:

.gamer-zone {
  position: absolute;
}
.gamer-zone-card {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%) translateY(4px);
  min-width: 220px;
  max-width: 260px;
  padding: 12px;
  border-radius: 10px;
  background: rgb(15 17 25 / 0.96);
  border: 1px solid rgb(255 255 255 / 0.08);
  box-shadow: 0 12px 30px rgb(0 0 0 / 0.45);
  opacity: 0;
  pointer-events: none;
  transition: opacity 120ms ease-out, transform 120ms ease-out;
  z-index: 30;
}
.gamer-zone:hover .gamer-zone-card,
.gamer-zone:focus-within .gamer-zone-card,
.gamer-zone.is-open .gamer-zone-card {
  opacity: 1;
  pointer-events: auto;
  transform: translateX(-50%) translateY(0);
}
.gamer-zone.right-\[12\%\] .gamer-zone-card,
.gamer-zone.right-\[15\%\] .gamer-zone-card,
.gamer-zone.right-\[30\%\] .gamer-zone-card { left: auto; right: 0; transform: translateY(4px); }
.gamer-zone.right-\[12\%\]:hover .gamer-zone-card,
.gamer-zone.right-\[15\%\]:hover .gamer-zone-card,
.gamer-zone.right-\[30\%\]:hover .gamer-zone-card { transform: translateY(0); }
.gamer-zone-card-list { display: flex; flex-direction: column; gap: 6px; }
.gamer-zone-card-row { display: flex; justify-content: space-between; gap: 10px; font-size: 12px; color: rgb(226 232 240); }
.gamer-zone-card-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.gamer-zone-card-stat dt { font-size: 10px; color: rgb(148 163 184); }
.gamer-zone-card-stat dd { font-size: 13px; color: rgb(255 255 255); font-weight: 700; }
.gamer-zone-card-cta { margin-top: 8px; font-size: 10px; color: rgb(110 231 183); }
.gamer-zone-card-icons { display: flex; gap: 6px; }
  • Step 7: Stimulus controller for tap behavior

app/javascript/controllers/gamer_map_zone_controller.js:

import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["card", "trigger"]

  toggle(event) {
    if (this.matchMediaCoarse()) {
      if (!this.element.classList.contains("is-open")) {
        event.preventDefault()
        this.element.classList.add("is-open")
      }
    }
  }

  close() {
    this.element.classList.remove("is-open")
  }

  closeIfOutside(event) {
    if (!this.element.contains(event.target)) this.close()
  }

  matchMediaCoarse() {
    return window.matchMedia && window.matchMedia("(hover: none)").matches
  }
}

The controller only intercepts the click on coarse-pointer (touch) devices. On hover-capable devices, the <a> link navigates immediately as before — the CSS already shows the card on hover.

  • Step 8: Build _weekly_activity.html.erb
<%# locals: snapshot: %>
<div class="gamer-panel p-4">
  <h2 class="mb-3 text-[14px] font-black text-white"><%= t("gamer_dashboard.weekly_activity.title") %></h2>
  <div class="grid grid-cols-2 gap-2 text-center">
    <% snapshot.weekly_activity.each do |key, value| %>
      <div class="gamer-mini-stat">
        <strong><%= number_with_delimiter(value) %></strong>
        <span><%= t("gamer_dashboard.weekly_activity.#{key}") %></span>
      </div>
    <% end %>
  </div>
  <div class="mt-3 flex items-end gap-1.5 h-12">
    <% snapshot.weekly_xp_by_day.each do |date, xp| %>
      <% pct = xp.zero? ? 4 : [(xp.to_f / [snapshot.weekly_xp_by_day.map(&:last).max, 1].max * 100).to_i, 100].min %>
      <span class="flex-1 rounded bg-emerald-400/30 hover:bg-emerald-400/60"
            style="height: <%= pct %>%"
            title="<%= I18n.l(date, format: :short) %>: <%= xp %> XP"></span>
    <% end %>
  </div>
</div>
  • Step 9: Build _achievements_row.html.erb
<%# locals: snapshot: %>
<div class="gamer-panel p-4">
  <div class="flex items-center justify-between mb-3">
    <h2 class="text-[14px] font-black text-white"><%= t("gamer_dashboard.achievements_row.title") %></h2>
    <%= link_to t("gamer_dashboard.achievements_row.see_all"),
                gamification_achievements_path,
                class: "text-[10px] font-bold uppercase text-emerald-300 hover:text-emerald-200" %>
  </div>
  <% top = snapshot.top_difficulty_achievements(limit: 6) %>
  <% if top.empty? %>
    <p class="text-[11px] text-slate-500"><%= t("gamer_dashboard.achievements_row.empty") %></p>
  <% else %>
    <ul class="flex items-center gap-2">
      <% top.each do |ua| %>
        <li class="gamer-achievement-tile gamer-achievement-tile-<%= ua.rarity %>"
            title="<%= ua.name %> · <%= t("achievements.rarity.#{ua.rarity}", default: ua.rarity.titleize) %>">
          <span class="gamer-achievement-icon"><%= ua.icon %></span>
        </li>
      <% end %>
    </ul>
  <% end %>
</div>
  • Step 10: Restructure show.html.erb
<% content_for :page_full_width, true %>

<div class="gamer-dashboard min-h-[calc(100dvh-7rem)]">
  <div class="mb-4 flex flex-col gap-1 sm:flex-row sm:items-end sm:justify-between">
    <div>
      <h1 class="text-[22px] font-black text-white"><%= t("gamer_dashboard.title") %></h1>
      <p class="text-[12px] text-slate-400"><%= t("gamer_dashboard.subtitle") %></p>
    </div>
    <%= link_to gamification_path, class: "gamer-top-level inline-flex items-center gap-2" do %>
      <%= image_tag @snapshot.character_asset_path, class: "size-8 object-contain image-render-pixel", alt: "" %>
      <span><%= t("gamer_dashboard.level", level: @snapshot.level_progress[:level]) %></span>
    <% end %>
  </div>

  <div class="grid grid-cols-1 gap-3 xl:grid-cols-[280px_minmax(0,1fr)_320px]">
    <aside class="space-y-3">
      <%= render "player_panel",       snapshot: @snapshot %>
      <%= render "habits_panel",       snapshot: @snapshot %>
      <%= render "weekly_activity",    snapshot: @snapshot %>
      <%= render "achievements_row",   snapshot: @snapshot %>
    </aside>

    <section class="min-w-0">
      <%= render "world_map", snapshot: @snapshot %>
    </section>

    <aside class="space-y-3">
      <%= render "finance_panel",      snapshot: @snapshot %>
      <%= render "gamification_panel", snapshot: @snapshot %>
    </aside>
  </div>
</div>
  • Step 11: Delete the four obsolete partials
git rm app/views/gamer_dashboard/_quest_feed.html.erb \
       app/views/gamer_dashboard/_tasks_panel.html.erb \
       app/views/gamer_dashboard/_goals_panel.html.erb \
       app/views/gamer_dashboard/_sports_panel.html.erb

If the helper exposes any methods that were only used by those partials (e.g. gamer_quest_path, gamer_quest_subtitle), confirm with grep before removing. They are reused in the achievements row tooltip and stay.

  • Step 12: Translations

Add to config/locales/en.yml under gamer_dashboard:

weekly_activity:
  title: Weekly Activity
  habit_completions: Habits done
  tasks_completed: Tasks done
  activity_logs: Workouts
  activity_minutes: Minutes
  xp_earned: XP earned
achievements_row:
  title: Top Achievements
  see_all: See all
  empty: Unlock your first achievement to see it here.
zones:
  open: Open
  previews:
    empty: Nothing here yet.
    finance:
      patrimony: Patrimony
      expenses: Expenses
      investments_pct: Investments
      growth: Growth
    levels:
      current: Level

Mirror in pt-BR.yml (translations: "Atividade Semanal", "Hábitos feitos", "Tarefas feitas", "Treinos", "Minutos", "XP ganho", "Top Conquistas", "Ver todas", etc.).

  • Step 13: Update controller render assertions

In test/controllers/gamer_dashboard_controller_test.rb, replace assertions for the removed partials with assertions for the new sections:

test "shows the new left-column sections" do
  sign_in users(:admin)
  get gamer_url

  assert_select ".gamer-panel h2", text: I18n.t("gamer_dashboard.weekly_activity.title")
  assert_select ".gamer-panel h2", text: I18n.t("gamer_dashboard.achievements_row.title")
end

test "renders eight zone cards on the world map" do
  sign_in users(:admin)
  get gamer_url

  assert_select ".gamer-zone-card", count: 8
end

test "no longer renders the obsolete center-column panels" do
  sign_in users(:admin)
  get gamer_url

  obsolete_titles = [
    I18n.t("gamer_dashboard.quest_feed.title", default: "Active Quests"),
    I18n.t("gamer_dashboard.task_board.title", default: "Task Board"),
    I18n.t("gamer_dashboard.goal_temple.title", default: "Goal Temple"),
    I18n.t("gamer_dashboard.training.title", default: "Training Grounds")
  ]
  obsolete_titles.each do |title|
    assert_select ".gamer-panel h2", text: title, count: 0
  end
end
  • Step 14: Run targeted tests
bin/rails test test/controllers/gamer_dashboard_controller_test.rb \
               test/models/gamer_dashboard/snapshot_test.rb

Expected: GREEN.

  • Step 15: Manual smoke test
bin/dev

Visit http://localhost:3004/gamer and confirm:

  • The center column shows ONLY the world map; no panels beneath it.
  • Hovering each of the 8 map zones reveals a card with up to 4 preview rows.
  • Clicking any zone or card navigates to the corresponding page.
  • Left column shows: Player, Habit Forest (with toggles), Weekly Activity, Achievements Row.
  • The Achievements Row shows only icons, sorted by rarity, with a "see all" link.
  • On a touch device (or simulated coarse pointer in DevTools), the first tap opens the card and a second tap navigates.

  • Step 16: Commit
git add app/models/gamer_dashboard/snapshot.rb \
        app/views/gamer_dashboard \
        app/javascript/controllers/gamer_map_zone_controller.js \
        app/assets/tailwind/application.css \
        config/locales \
        test/controllers/gamer_dashboard_controller_test.rb \
        test/models/gamer_dashboard/snapshot_test.rb
git rm app/views/gamer_dashboard/_quest_feed.html.erb \
       app/views/gamer_dashboard/_tasks_panel.html.erb \
       app/views/gamer_dashboard/_goals_panel.html.erb \
       app/views/gamer_dashboard/_sports_panel.html.erb
git commit -m "feat(gamer): hover-driven map zones, weekly activity, achievements row"

Plan Self-Review

  • Spec coverage: /gamer, tasks, habits, finance, goals, sports/activity logs, gamification XP, achievements, challenges, levels, generated PNG characters, generated map, hover-card map zones, weekly activity panel, and achievements row are all covered.
  • Ownership: all data is gathered from current_user through GamerDashboard::Snapshot; no organizations or memberships are introduced. Each zone preview reader scopes through user-owned associations.
  • Rails conventions: controller is thin, Pundit policy is singleton-style, complex aggregation lives in a namespaced model class, tests use Minitest fixtures.
  • DRY: every map zone uses the single _zone.html.erb partial which dispatches to a per-slug _zone_preview/*.html.erb.
  • Performance: weekly aggregations and zone previews are computed once per request inside the snapshot, capped at 4 records per zone, with .includes where they cross associations.
  • Asset choice: Option 1 (generated PNGs) shipped for character sprites and the map.
  • Verification: targeted tests, manual smoke at /gamer, and bin/ci are included.