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, the vision_autosave_controller, and the Vision::Serializer.merge! write path.
  • Performance: one row, one indexed where(user_id:). Public profile renders read a 5-minute cached public_sections map.
  • 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_sections per 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"

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 /vision to 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.