Vision 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 /vision page with six contemplative sections (Letter from 100, Bucket List, Mission, Definition of Success, Odyssey Plan, Future Calendar), all stored as one JSON column on a per-user Vision row. Inline autosave via Turbo Stream, per-section public/private toggle, and a strict serializer that prevents arbitrary JSON writes.
Architecture: A single Vision model owns a content JSON column. Vision::Serializer is the only write path; it validates one section at a time against an allowlisted schema. VisionsController#update accepts a per-section slice and merges atomically. Each section has its own ERB partial and (where needed) Stimulus controller.
Tech Stack: Rails 8.1, Pundit, ULID for in-document ids (via securerandom UUIDv7-ish strings), Turbo Streams, Stimulus, Tailwind CSS v4, Minitest fixtures.
Cross-cutting principles enforced in every task:
- DRY: every section reuses
_section_header.html.erb, thevision_autosave_controller, and theVision::Serializer.merge!write path. - Performance: one row, one indexed
where(user_id:). Public profile renders read a 5-minute cachedpublic_sectionsmap. - Security: three-layer write defense (strong params → section schema allowlist → atomic merge). Length caps on every text field. No HTML allowed in stored content; sanitization on render.
File Structure
- Create:
db/migrate/<ts>_create_visions.rb - Create:
app/models/vision.rb - Create:
app/models/vision/serializer.rb - Create:
app/models/vision/section.rb - Modify:
app/models/user.rb(has_one :vision, dependent: :destroy) - Create:
app/controllers/visions_controller.rb - Create:
app/policies/vision_policy.rb - Create:
app/views/visions/show.html.erb - Create:
app/views/visions/_section_header.html.erb - Create:
app/views/visions/_letter_from_100.html.erb - Create:
app/views/visions/_bucket_list.html.erb - Create:
app/views/visions/_mission.html.erb - Create:
app/views/visions/_definition_of_success.html.erb - Create:
app/views/visions/_odyssey_plan.html.erb - Create:
app/views/visions/_future_calendar.html.erb - Create:
app/views/visions/update.turbo_stream.erb - Create:
app/views/users/profiles/_vision_public.html.erb - Create:
app/javascript/controllers/vision_section_controller.js - Create:
app/javascript/controllers/vision_autosave_controller.js - Create:
app/javascript/controllers/vision_bucket_list_controller.js - Create:
app/javascript/controllers/vision_future_calendar_controller.js - Modify:
config/routes.rb - Modify:
app/views/shared/_sidebar.html.erb - Modify:
app/views/shared/_navbar.html.erb - Modify:
app/views/shared/_footer_nav.html.erb - Modify:
app/helpers/application_helper.rb(add icon) - Modify:
config/locales/en.yml - Modify:
config/locales/pt-BR.yml - Create:
test/controllers/visions_controller_test.rb - Create:
test/models/vision_test.rb - Create:
test/models/vision/serializer_test.rb - Create:
test/fixtures/visions.yml
Task 1: Create the visions Table
Files:
-
Create:
db/migrate/<ts>_create_visions.rb -
Step 1: Generate migration
bin/rails g migration create_visions user:references content:json
- Step 2: Edit migration
class CreateVisions < ActiveRecord::Migration[8.1]
def change
create_table :visions do |t|
t.references :user, null: false, foreign_key: true
t.json :content, null: false, default: {}
t.timestamps
end
add_index :visions, :user_id, unique: true
end
end
- Step 3: Run migration
bin/rails db:migrate
- Step 4: Commit
git add db/migrate db/schema.rb
git commit -m "feat: create visions table"
Task 2: Vision Model + Default Content + User Association
Files:
- Create:
app/models/vision.rb - Modify:
app/models/user.rb - Create:
test/fixtures/visions.yml -
Create:
test/models/vision_test.rb - Step 1: Failing model test
require "test_helper"
class VisionTest < ActiveSupport::TestCase
test "default content has all six sections" do
vision = Vision.new(user: users(:admin), content: Vision::DEFAULT_CONTENT.deep_dup)
Vision::SECTIONS.each { |s| assert vision.content.key?(s.to_s), "missing #{s}" }
end
test "is unique per user" do
Vision.create!(user: users(:admin), content: Vision::DEFAULT_CONTENT.deep_dup)
duplicate = Vision.new(user: users(:admin), content: {})
refute duplicate.valid?
end
test "public_sections returns only sections with public: true" do
content = Vision::DEFAULT_CONTENT.deep_dup
content["mission"]["public"] = true
vision = Vision.create!(user: users(:admin), content: content)
assert_equal ["mission"], vision.public_sections.keys
end
end
bin/rails test test/models/vision_test.rb
Expected: FAIL.
- Step 2: Implement model
class Vision < ApplicationRecord
belongs_to :user
validates :user_id, uniqueness: true
SECTIONS = %i[letter_from_100 bucket_list mission definition_of_success odyssey_plan future_calendar].freeze
DEFAULT_CONTENT = {
"letter_from_100" => { "text" => "", "public" => false },
"bucket_list" => { "items" => [], "public" => false },
"mission" => { "text" => "", "public" => false },
"definition_of_success" => {
"career" => "", "health" => "", "relationships" => "",
"financial" => "", "growth" => "", "impact" => "",
"public" => false
},
"odyssey_plan" => {
"current_path" => { "text" => "", "tags" => [] },
"alternative_path" => { "text" => "", "tags" => [] },
"radical_path" => { "text" => "", "tags" => [] },
"public" => false
},
"future_calendar" => {
"ideal_tuesday" => { "blocks" => [] },
"ideal_sunday" => { "blocks" => [] },
"public" => false
}
}.freeze
def section(name) = Vision::Section.new(self, name.to_s)
def public_sections = content.select { |_, slice| slice.is_a?(Hash) && slice["public"] == true }
def cache_key_with_version = "visions/#{id}-#{updated_at.to_i}"
end
- Step 3: User association
In app/models/user.rb:
has_one :vision, dependent: :destroy
- Step 4: Fixture
# test/fixtures/visions.yml
admin_vision:
user: admin
content: |
{"letter_from_100":{"text":"","public":false},
"bucket_list":{"items":[],"public":false},
"mission":{"text":"","public":false},
"definition_of_success":{"career":"","health":"","relationships":"","financial":"","growth":"","impact":"","public":false},
"odyssey_plan":{"current_path":{"text":"","tags":[]},"alternative_path":{"text":"","tags":[]},"radical_path":{"text":"","tags":[]},"public":false},
"future_calendar":{"ideal_tuesday":{"blocks":[]},"ideal_sunday":{"blocks":[]},"public":false}}
- Step 5: Tests pass
bin/rails test test/models/vision_test.rb
- Step 6: Commit
git commit -am "feat: Vision model with default content and per-user uniqueness"
Task 3: Vision::Section Value Object
Files:
-
Create:
app/models/vision/section.rb -
Step 1: Implement
class Vision::Section
attr_reader :name
def initialize(vision, name)
raise ArgumentError, "unknown section #{name}" unless Vision::SECTIONS.map(&:to_s).include?(name)
@vision = vision
@name = name
end
def slice = @vision.content.fetch(name, {})
def public? = slice["public"] == true
end
(No tests needed; covered by VisionTest and serializer tests via the Vision#section method.)
Task 4: Vision::Serializer — The Only Write Path
Files:
- Create:
app/models/vision/serializer.rb -
Create:
test/models/vision/serializer_test.rb - Step 1: Failing serializer tests
require "test_helper"
class Vision::SerializerTest < ActiveSupport::TestCase
setup do
@vision = Vision.create!(user: users(:admin), content: Vision::DEFAULT_CONTENT.deep_dup)
end
test "merges letter_from_100 text without touching other sections" do
Vision::Serializer.merge!(@vision, section: "letter_from_100", slice: { "text" => "I lived gladly.", "public" => true })
@vision.reload
assert_equal "I lived gladly.", @vision.content["letter_from_100"]["text"]
assert @vision.content["letter_from_100"]["public"]
assert_equal "", @vision.content["mission"]["text"]
end
test "rejects unknown section" do
assert_raises(Vision::Serializer::InvalidSection) do
Vision::Serializer.merge!(@vision, section: "secret_handshake", slice: { "text" => "x" })
end
end
test "rejects unknown key inside known section" do
assert_raises(Vision::Serializer::InvalidKey) do
Vision::Serializer.merge!(@vision, section: "mission", slice: { "evil_field" => "x" })
end
end
test "caps oversized text" do
huge = "x" * (Vision::Serializer::TEXT_LIMITS[:letter_from_100] + 1)
assert_raises(Vision::Serializer::InvalidValue) do
Vision::Serializer.merge!(@vision, section: "letter_from_100", slice: { "text" => huge })
end
end
test "generates ulid ids for new bucket list items" do
Vision::Serializer.merge!(@vision, section: "bucket_list", slice: {
"items" => [{ "text" => "Run a marathon", "done" => false }]
})
item = @vision.reload.content["bucket_list"]["items"].first
assert item["id"].is_a?(String)
assert item["id"].length >= 16
end
test "future_calendar sorts blocks by time" do
Vision::Serializer.merge!(@vision, section: "future_calendar", slice: {
"ideal_tuesday" => { "blocks" => [
{ "time" => "09:00", "label" => "Work" },
{ "time" => "06:00", "label" => "Run" }
]}
})
times = @vision.reload.content["future_calendar"]["ideal_tuesday"]["blocks"].map { |b| b["time"] }
assert_equal %w[06:00 09:00], times
end
test "future_calendar rejects bad time format" do
assert_raises(Vision::Serializer::InvalidValue) do
Vision::Serializer.merge!(@vision, section: "future_calendar", slice: {
"ideal_tuesday" => { "blocks" => [{ "time" => "9am", "label" => "Run" }] }
})
end
end
end
- Step 2: Implement serializer
class Vision::Serializer
class Error < StandardError; end
class InvalidSection < Error; end
class InvalidKey < Error; end
class InvalidValue < Error; end
TEXT_LIMITS = {
letter_from_100: 8_000,
bucket_list_item: 280,
mission: 4_000,
success_category: 1_000,
odyssey_path: 4_000,
future_calendar_label: 200
}.freeze
SUCCESS_CATEGORIES = %w[career health relationships financial growth impact].freeze
ODYSSEY_PATHS = %w[current_path alternative_path radical_path].freeze
ODYSSEY_TAGS = %w[self work relationships].freeze
CALENDAR_DAYS = %w[ideal_tuesday ideal_sunday].freeze
TIME_FORMAT = /\A\d{2}:\d{2}\z/.freeze
def self.merge!(vision, section:, slice:)
raise InvalidSection, section unless Vision::SECTIONS.map(&:to_s).include?(section.to_s)
sanitized = send("normalize_#{section}", slice.to_h.deep_stringify_keys)
new_content = vision.content.deep_dup
new_content[section.to_s] = sanitized
vision.update!(content: new_content)
vision
end
## --- Per-section normalizers (each is a single small method) ---
def self.normalize_letter_from_100(slice)
allow_keys!(slice, %w[text public])
{
"text" => bounded_text(slice["text"], limit: TEXT_LIMITS[:letter_from_100]),
"public" => boolean(slice["public"])
}
end
def self.normalize_mission(slice)
allow_keys!(slice, %w[text public])
{
"text" => bounded_text(slice["text"], limit: TEXT_LIMITS[:mission]),
"public" => boolean(slice["public"])
}
end
def self.normalize_bucket_list(slice)
allow_keys!(slice, %w[items public])
items = Array(slice["items"]).map { |item|
allow_keys!(item, %w[id text done])
{
"id" => item["id"].presence || generate_id,
"text" => bounded_text(item["text"], limit: TEXT_LIMITS[:bucket_list_item]),
"done" => boolean(item["done"])
}
}
{ "items" => items, "public" => boolean(slice["public"]) }
end
def self.normalize_definition_of_success(slice)
allow_keys!(slice, SUCCESS_CATEGORIES + %w[public])
SUCCESS_CATEGORIES.each_with_object({}) { |cat, h| h[cat] = bounded_text(slice[cat], limit: TEXT_LIMITS[:success_category]) }
.merge("public" => boolean(slice["public"]))
end
def self.normalize_odyssey_plan(slice)
allow_keys!(slice, ODYSSEY_PATHS + %w[public])
ODYSSEY_PATHS.each_with_object({}) { |path, h|
sub = slice[path].to_h
allow_keys!(sub, %w[text tags])
h[path] = {
"text" => bounded_text(sub["text"], limit: TEXT_LIMITS[:odyssey_path]),
"tags" => Array(sub["tags"]).map(&:to_s).select { |t| ODYSSEY_TAGS.include?(t) }
}
}.merge("public" => boolean(slice["public"]))
end
def self.normalize_future_calendar(slice)
allow_keys!(slice, CALENDAR_DAYS + %w[public])
CALENDAR_DAYS.each_with_object({}) { |day, h|
sub = slice[day].to_h
allow_keys!(sub, %w[blocks])
blocks = Array(sub["blocks"]).map { |b|
allow_keys!(b, %w[id time label])
time = b["time"].to_s
raise InvalidValue, "time must be HH:MM" unless time.match?(TIME_FORMAT)
{
"id" => b["id"].presence || generate_id,
"time" => time,
"label" => bounded_text(b["label"], limit: TEXT_LIMITS[:future_calendar_label])
}
}.sort_by { |b| b["time"] }
h[day] = { "blocks" => blocks }
}.merge("public" => boolean(slice["public"]))
end
## --- helpers ---
def self.allow_keys!(hash, keys)
extra = hash.keys - keys
raise InvalidKey, "unknown keys: #{extra.join(', ')}" if extra.any?
end
def self.bounded_text(value, limit:)
# Strip control bytes (preserve \n and \t), scrub invalid encodings.
text = value.to_s.scrub.gsub(/[\x00-\x08\x0B-\x1F\x7F]/, "")
raise InvalidValue, "text exceeds #{limit} characters" if text.length > limit
text
end
def self.boolean(value)
[true, "true", "1", 1].include?(value)
end
def self.generate_id
SecureRandom.uuid_v7
rescue NoMethodError
SecureRandom.uuid
end
end
- Step 3: Tests pass
bin/rails test test/models/vision/serializer_test.rb
- Step 4: Commit
git commit -am "feat: Vision::Serializer with per-section schema allowlist"
Task 5: Routes, Controller, Policy, Access Tests
Files:
- Modify:
config/routes.rb - Create:
app/controllers/visions_controller.rb - Create:
app/policies/vision_policy.rb - Create:
app/views/visions/show.html.erb -
Create:
test/controllers/visions_controller_test.rb - Step 1: Failing controller tests
require "test_helper"
class VisionsControllerTest < ActionDispatch::IntegrationTest
include Devise::Test::IntegrationHelpers
setup { sign_in users(:admin) }
test "GET /vision creates a Vision row lazily and renders" do
assert_difference -> { Vision.count }, 1 do
get vision_url
end
assert_response :success
assert_select "h1", I18n.t("vision.title")
end
test "PATCH /vision updates only the requested section" do
vision = Vision.create!(user: users(:admin), content: Vision::DEFAULT_CONTENT.deep_dup)
patch vision_url(format: :turbo_stream),
params: { vision: { section: { name: "mission", slice: { text: "Lead with impact", public: "1" } } } }
assert_response :success
assert_equal "Lead with impact", vision.reload.content["mission"]["text"]
assert vision.content["mission"]["public"]
assert_equal "", vision.content["letter_from_100"]["text"]
end
test "PATCH rejects unknown section" do
Vision.create!(user: users(:admin), content: Vision::DEFAULT_CONTENT.deep_dup)
patch vision_url, params: { vision: { section: { name: "bogus", slice: { text: "x" } } } }
assert_response :unprocessable_entity
end
test "two users do not share visions" do
sign_in users(:member_one)
get vision_url
assert_equal users(:member_one).id, Vision.last.user_id
end
end
- Step 2: Add route
authenticated :user do
resource :vision, only: %i[show update]
end
- Step 3: Implement policy
class VisionPolicy < ApplicationPolicy
def show? = record.user_id == user.id
def update? = record.user_id == user.id
end
- Step 4: Implement controller
class VisionsController < ApplicationController
before_action :set_vision
def show
authorize @vision
end
def update
authorize @vision
section = params.dig(:vision, :section, :name).to_s
slice = params.dig(:vision, :section, :slice)&.permit!.to_h.deep_stringify_keys || {}
Vision::Serializer.merge!(@vision, section: section, slice: slice)
respond_to do |format|
format.turbo_stream
format.html { redirect_to vision_path }
end
rescue Vision::Serializer::Error => e
@error = e.message
respond_to do |format|
format.turbo_stream { render status: :unprocessable_entity }
format.html { redirect_to vision_path, alert: e.message }
end
end
private
def set_vision
@vision = current_user.vision || current_user.create_vision!(content: Vision::DEFAULT_CONTENT.deep_dup)
end
end
- Step 5: Minimal show.html.erb so access tests pass
<h1><%= t("vision.title") %></h1>
<p><%= t("vision.subtitle") %></p>
- Step 6: Translations
# en.yml
vision:
title: Vision
subtitle: Define your north star — the life you're building toward
saved: Saved
public: Public
private: Private
sections:
letter_from_100:
title: Letter from 100
tagline: Imagine you've lived to 100. What would you want people to say about how you lived?
bucket_list:
title: The Bucket List
tagline: Dream without limits — what do you want to do, be, learn, create, and experience?
add: Add another dream
mission:
title: The Mission Prompt
tagline: How would you serve others if money and fear weren't factors?
example: 'Example: "Help motivated people achieve more by building tools that bring balance between work and family life."'
definition_of_success:
title: Definition of Success
tagline: Imagine yourself in complete contentment. What does your life look like?
categories:
career: Career
health: Health
relationships: Relationships
financial: Financial
growth: Growth
impact: Impact
odyssey_plan:
title: Odyssey Plan
tagline: 3 possible life paths you could take over the next 5–10 years
paths:
current_path:
title: Current Path
tagline: Where will you be in 5–10 years if you continue on your current trajectory?
alternative_path:
title: Alternative Path
tagline: If your current path wasn't available, what fulfilling alternative would you pursue?
radical_path:
title: Radical Path
tagline: If money, fear, and expectations weren't factors, what would you do?
tags:
self: Self
work: Work
relationships: Relationships
future_calendar:
title: Future Calendar
tagline: Your ideal days, 3 years from now
ideal_tuesday: Ideal Tuesday
ideal_sunday: Ideal Sunday
Mirror in pt-BR.yml (translations: "Carta dos 100", "Lista dos sonhos", "Sua Missão", "Definição de Sucesso", "Plano Odyssey", "Calendário Futuro", etc.).
- Step 7: Controller tests pass
bin/rails test test/controllers/visions_controller_test.rb
- Step 8: Commit
git commit -am "feat: Vision route, controller, policy, lazy-create"
Task 6: Section Header Partial + Letter from 100 (end-to-end pattern)
Files:
- Create:
app/views/visions/_section_header.html.erb - Create:
app/views/visions/_letter_from_100.html.erb - Create:
app/views/visions/update.turbo_stream.erb - Create:
app/javascript/controllers/vision_section_controller.js -
Create:
app/javascript/controllers/vision_autosave_controller.js - Step 1:
_section_header.html.erb
Reusable header. Inputs: key:, vision:. Renders icon, title, tagline, public/private toggle (a hidden checkbox bound to vision[section][slice][public]).
- Step 2:
_letter_from_100.html.erb
<%# locals: vision: %>
<section class="vision-section" data-controller="vision-section vision-autosave"
data-vision-autosave-section-value="letter_from_100">
<%= render "section_header", key: :letter_from_100, vision: vision %>
<%= form_with model: vision, url: vision_path, method: :patch,
data: { vision_autosave_target: "form", turbo: true } do |f| %>
<%= hidden_field_tag "vision[section][name]", "letter_from_100" %>
<%= text_area_tag "vision[section][slice][text]",
vision.section(:letter_from_100).slice["text"],
rows: 8,
class: "vision-textarea",
data: { vision_autosave_target: "field" },
maxlength: 8000 %>
<%= hidden_field_tag "vision[section][slice][public]",
vision.section(:letter_from_100).public? ? "1" : "0" %>
<% end %>
</section>
- Step 3: Stimulus autosave controller
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["form", "field"]
static values = { section: String }
connect() {
this.handler = this.debounce(() => this.formTarget.requestSubmit(), 400)
this.fieldTargets.forEach(f => f.addEventListener("blur", this.handler))
}
disconnect() {
if (!this.handler) return
this.fieldTargets.forEach(f => f.removeEventListener("blur", this.handler))
}
debounce(fn, ms) {
let t
return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms) }
}
}
- Step 4:
update.turbo_stream.erb
<%= turbo_stream.replace dom_id(@vision, :saved_indicator) do %>
<span id="<%= dom_id(@vision, :saved_indicator) %>" class="vision-saved" data-controller="vision-section">
<%= t("vision.saved") %>
</span>
<% end %>
- Step 5: Wire show.html.erb
<section class="mx-auto max-w-3xl px-4 py-8 space-y-4">
<header>
<h1 class="text-3xl font-bold"><%= t("vision.title") %></h1>
<p class="text-gray-400"><%= t("vision.subtitle") %></p>
<span id="<%= dom_id(@vision, :saved_indicator) %>" class="vision-saved hidden"></span>
</header>
<%= render "letter_from_100", vision: @vision %>
<%# rest of sections added in Task 7 %>
</section>
- Step 6: Tests pass + commit
bin/rails test test/controllers/visions_controller_test.rb
git commit -am "feat: Vision letter_from_100 with autosave"
Task 7: Bucket List Section
Files:
- Create:
app/views/visions/_bucket_list.html.erb - Create:
app/javascript/controllers/vision_bucket_list_controller.js -
Modify:
app/views/visions/show.html.erb - Step 1: Failing controller test
test "bucket list adds and removes items" do
vision = Vision.create!(user: users(:admin), content: Vision::DEFAULT_CONTENT.deep_dup)
patch vision_url(format: :turbo_stream),
params: { vision: { section: { name: "bucket_list", slice: { items: [{ text: "Run a marathon", done: "0" }] } } } }
vision.reload
assert_equal 1, vision.content["bucket_list"]["items"].size
assert vision.content["bucket_list"]["items"].first["id"].present?
end
- Step 2: Implement partial
_bucket_list.html.erb renders one row per item, plus an "add" button. The Stimulus controller mutates a hidden <input type=hidden name="vision[section][slice][items][]"> array and submits on blur.
- Step 3: Stimulus
// vision_bucket_list_controller.js
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["template", "list", "form"]
add() {
const node = this.templateTarget.content.cloneNode(true)
this.listTarget.append(node)
}
remove(event) {
const row = event.target.closest("[data-bucket-row]")
row?.remove()
this.formTarget.requestSubmit()
}
}
- Step 4: Wire into show.html.erb + tests + commit
Task 8: Mission Section
Reuse the autosave pattern from Letter from 100. Single textarea + ghost example line beneath. Add to show.html.erb after Bucket List.
git commit -am "feat: Vision mission section"
Task 9: Definition of Success Section
2×3 responsive grid. Each tile is a self-contained autosave form posting to the same definition_of_success section but with only its single category field. Server-side merge keeps untouched categories intact (verified by serializer behavior).
git commit -am "feat: Vision definition of success grid"
Task 10: Odyssey Plan Section
Three vertically stacked path cards. Each card has a textarea + a row of three tag chips (Self / Work / Relationships) implemented as toggle buttons that mutate a hidden multi-value field. Submit on blur.
git commit -am "feat: Vision odyssey plan section"
Task 11: Future Calendar Section
Two side-by-side cards: Ideal Tuesday and Ideal Sunday. Each card lists time blocks. The Stimulus controller renders an inline editor when a row is clicked: HH:MM input + label input + delete button. Submission rebuilds the blocks array and submits the form. Server sorts by time (already in the serializer).
git commit -am "feat: Vision future calendar section"
Task 12: Public Profile Integration
Files:
- Modify:
app/controllers/users/profiles_controller.rb(introduced in Leaderboard plan) -
Create:
app/views/users/profiles/_vision_public.html.erb - Step 1: Cache
public_sectionsper vision row
In Vision:
after_update_commit :bust_public_sections_cache
def cached_public_sections
Rails.cache.fetch("vision/#{id}/public_sections/v#{updated_at.to_i}", expires_in: 5.minutes) { public_sections }
end
private
def bust_public_sections_cache
Rails.cache.delete_matched("vision/#{id}/public_sections/*")
end
- Step 2: Render only public sections on the profile
_vision_public.html.erb iterates Vision::SECTIONS.select { |s| sections.key?(s.to_s) } and renders read-only versions of each section. No autosave forms.
- Step 3: Sanitization
Render text values via simple_format(sanitize(text, tags: [])) — strict allowlist (no HTML), preserves line breaks.
- Step 4: Tests + commit
Add a controller test:
test "profile shows only public vision sections" do
vision = Vision.create!(user: users(:admin), content: Vision::DEFAULT_CONTENT.deep_dup)
Vision::Serializer.merge!(vision, section: "mission", slice: { "text" => "Public mission", "public" => true })
get user_profile_url(username: users(:admin).username)
assert_select "[data-section=mission]", text: /Public mission/
assert_select "[data-section=letter_from_100]", count: 0
end
git commit -am "feat: render public Vision sections on user profile"
Task 13: Sidebar / Navbar / Footer / Icon
Same pattern as Leaderboard task 12. Add Vision link with a compass / north-star icon (inline SVG, currentColor).
git commit -am "feat: Vision navigation links"
Task 14: Rate Limiting + Brakeman
Files:
-
Modify:
config/initializers/rack_attack.rb -
Step 1: Throttle
PATCH /visionto 30 req/min/user
Rack::Attack.throttle("vision/user", limit: 30, period: 1.minute) do |req|
req.session[:user_id] || req.env["warden"].user&.id if req.path == "/vision" && req.patch?
end
- Step 2: Run Brakeman
bundle exec brakeman -q --no-pager
Expected: no warnings introduced. If Brakeman flags the JSON write, justify with the per-section allowlist and the absence of any update(params[:vision]) call on raw input.
- Step 3: Commit
git commit -am "feat: rate limit Vision writes"
Task 15: Final Verification
- Step 1: Targeted tests
bin/rails test test/controllers/visions_controller_test.rb \
test/models/vision_test.rb \
test/models/vision/serializer_test.rb
- Step 2: Full CI
bin/ci
- Step 3: Manual smoke (optional)
Open /vision, type into each section, verify autosave indicator, refresh and confirm content persists, toggle public/private, visit /@username and confirm public sections render and private sections don't.