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.
- Thin HTTP controller for
- 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
/gamerroute.
- Add authenticated
- 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
gamericon case tosidebar_iconandnav_linkicon handling.
- Add
- Modify:
config/locales/en.yml- Add
nav.gamerandgamer_dashboard.*.
- Add
- Modify:
config/locales/pt-BR.yml- Add
nav.gamerandgamer_dashboard.*.
- Add
- 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
gamericon 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
/gamerin 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 doblock (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) becauseassert_routingcollides 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.erbpartial whichrenders a_zone_preview/<slug>.html.erbbased on the zone slug. No per-zone copy-paste in the world map. - Performance: all preview data is loaded by
GamerDashboard::Snapshotin the same pass as the existing summaries — no extra controller queries. Preview lists are capped at 4 records and use.includeswhere 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::Snapshotwith 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.erbpartial
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_previewpartials
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.erbto 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_userthroughGamerDashboard::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.erbpartial 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
.includeswhere they cross associations. - Asset choice: Option 1 (generated PNGs) shipped for character sprites and the map.
- Verification: targeted tests, manual smoke at
/gamer, andbin/ciare included.