Vision Project Plan

Goal

Add an authenticated /vision page where each user can articulate their long-term life direction across six structured exercises, all stored in a single JSON column on a per-user Vision row. The page is the user's "north star" and is referenced by the leaderboard's public profile pages.

Inspiration

Visual and structural reference: the loggd.life vision page (https://loggd.life/@demo/vision). We mirror its six sections and the per-section public/private toggle, but rename "The Eulogy Method" to Letter from 100 and adapt copy to Lifehub's voice. The persistence model is intentionally simpler than loggd's β€” one row, one JSON column.

User Experience

The first screen should answer:

  • What kind of life am I building toward?
  • What do I want to do, be, learn, create, and experience?
  • How do I want to be remembered?
  • What does success actually look like to me?

The page is contemplative, not gamified. Cards stack vertically. Each section can be collapsed independently. Edits autosave on blur via Turbo Stream so the user can write without ever clicking "Save". Each section has a Public / Private toggle that controls whether it appears on the user's public profile (/@:username).

Route

resource :vision, only: %i[show update]

URLs: GET /vision, PATCH /vision (autosave-friendly singular resource).

Public sections additionally render through Users::ProfilesController#show at /@:username (introduced in the Leaderboard plan).

Sections

The page renders six sections in this order, each as a partial reading from one slice of the JSON column.

1. Letter from 100

Single textarea. Tagline: "Imagine you've lived to 100. What would you want people to say about how you lived?"

This replaces loggd's "The Eulogy Method". Same prompt depth, less morbid framing.

JSON slice:

"letter_from_100": { "text": "...", "public": false }

2. The Bucket List

Checklist of dreams. User adds rows; each row has text and a done boolean. "Add another dream" link at the bottom.

JSON slice:

"bucket_list": {
  "items": [
    { "id": "01HV...", "text": "Run a marathon", "done": false },
    { "id": "01HV...", "text": "Read 100 books in a year", "done": true }
  ],
  "public": true
}

Each item has a ULID id so reorders and deletes don't depend on array index. Progress bar in the section header shows completed / total.

3. The Mission Prompt

Single textarea. Tagline: "How would you serve others if money and fear weren't factors?"

Below the textarea, a small ghost example line: "Example: Help motivated people achieve more by building tools that bring balance between work and family life."

JSON slice:

"mission": { "text": "...", "public": false }

4. Definition of Success

Six categories rendered in a 2Γ—3 grid. Each tile has its own icon, color accent, and textarea: Career, Health, Relationships, Financial, Growth, Impact.

JSON slice:

"definition_of_success": {
  "career": "...",
  "health": "...",
  "relationships": "...",
  "financial": "...",
  "growth": "...",
  "impact": "...",
  "public": false
}

5. Odyssey Plan

Three life paths: Current Path, Alternative Path, Radical Path. Each path has its own textarea and a row of focus tags (Self / Work / Relationships) that the user toggles to indicate which dimensions the path emphasizes.

JSON slice:

"odyssey_plan": {
  "current_path": {
    "text": "...",
    "tags": ["self", "work", "relationships"]
  },
  "alternative_path": { "text": "...", "tags": ["self", "work"] },
  "radical_path":     { "text": "...", "tags": ["self", "relationships"] },
  "public": false
}

6. Future Calendar

Two calendar cards: Ideal Tuesday (a representative weekday) and Ideal Sunday (a representative weekend day). Each card holds an array of time blocks ordered by time (HH:MM 24h string).

JSON slice:

"future_calendar": {
  "ideal_tuesday": {
    "blocks": [
      { "id": "01HV...", "time": "05:00", "label": "Wake naturally, no alarm" },
      { "id": "01HV...", "time": "06:00", "label": "Morning workout (strength or run)" }
    ]
  },
  "ideal_sunday": {
    "blocks": [
      { "id": "01HV...", "time": "08:00", "label": "Sleep in, slow morning" }
    ]
  },
  "public": false
}

Architecture

app/controllers/visions_controller.rb
app/models/vision.rb                    # belongs_to :user, content :json
app/models/vision/section.rb            # value object β€” section name, slice, public flag
app/models/vision/serializer.rb         # safe write merge / id generation / sort
app/policies/vision_policy.rb
app/views/visions/show.html.erb
app/views/visions/_letter_from_100.html.erb
app/views/visions/_bucket_list.html.erb
app/views/visions/_mission.html.erb
app/views/visions/_definition_of_success.html.erb
app/views/visions/_odyssey_plan.html.erb
app/views/visions/_future_calendar.html.erb
app/views/visions/_section_header.html.erb         # title, tagline, public/private toggle
app/javascript/controllers/vision_section_controller.js
app/javascript/controllers/vision_autosave_controller.js
app/javascript/controllers/vision_bucket_list_controller.js
app/javascript/controllers/vision_future_calendar_controller.js

VisionsController#update is the only mutation endpoint. It accepts a single section param identifying which slice to merge, and the new contents for just that slice. The controller delegates to Vision::Serializer.merge! which validates the slice shape, generates ULIDs for new items, sorts time blocks, and writes the merged JSON atomically.

class VisionsController < ApplicationController
  before_action :set_vision

  def show
    authorize @vision
  end

  def update
    authorize @vision
    @vision = Vision::Serializer.merge!(@vision, section: params[:section], slice: vision_params)
    respond_to do |format|
      format.turbo_stream
      format.html { redirect_to vision_path }
    end
  end

  private

  def set_vision
    @vision = current_user.vision || current_user.create_vision!(content: Vision::DEFAULT_CONTENT)
  end

  def vision_params
    params.expect(vision: [section: {}])[:section].to_unsafe_h
  end
end

Vision model:

class Vision < ApplicationRecord
  belongs_to :user
  validates :user_id, uniqueness: true

  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)
end

Vision::Serializer.merge! does input shape validation per section. It refuses unknown sections and unknown keys per section, which is the security boundary that protects the JSON column from arbitrary writes (see Security below).

Authorization

class VisionPolicy < ApplicationPolicy
  def show?   = record.user_id == user.id
  def update? = record.user_id == user.id
end

Public-section reads happen through Users::ProfilesController#show (Leaderboard plan) and pass the profile owner as the record, not the visiting user. Privacy is enforced by the serializer before rendering β€” only sections marked public: true are exposed.

Performance

  • Single row per user, single JSON column. Reads are an indexed where(user_id:) and a single deserialize.
  • Autosave is per-section, not per-keystroke β€” Stimulus debounces blur events 400ms and only sends the changed section's slice. Network traffic stays small.
  • The page eager-loads no other data; the partials render straight from the JSON.
  • Public profile reads cache Vision#public_sections for 5 minutes via solid_cache, invalidated on Vision#after_update.

Security

The JSON column is the entire write surface, which is exactly the kind of place mass-assignment bugs hide. Three layers of defense:

  1. Strong params β€” the controller only accepts params[:vision][:section] as a hash; it never trusts the top-level structure.
  2. Section allowlist β€” Vision::Serializer::SECTION_SCHEMAS maps each known section to its allowed keys/types. Unknown keys are rejected; values are coerced (strings sanitized to strip control chars, booleans coerced, time strings validated against \A\d{2}:\d{2}\z).
  3. Atomic merge β€” the serializer re-reads the existing JSON, merges the validated slice, and writes the whole document back in one update!. No SQL JSON path operations that could be exploited.

Other safeguards:

  • Each text field is capped (letter_from_100.text 8000 chars; bucket_list.items[].text 280 chars; success-category 1000 chars; calendar block label 200 chars). Validation rejects oversized writes with 422 and re-renders.
  • The id field on items/blocks is server-generated (ULID); client-supplied IDs are ignored on create and validated against the existing set on update/delete.
  • Public profile rendering passes content through sanitize with a strict allowlist (no HTML, line breaks preserved). Even if a future admin tool injects HTML, it can't reach a viewer.
  • Rate-limit PATCH /vision to 30 req/min/user to defeat content-flooding.

Visual Style

Dark-first to match the existing dashboard:

  • Each section is its own card with a subtle 1px border and a 24px corner radius.
  • Section header: small icon left, title, tagline below in muted gray, Public/Private toggle right.
  • Empty states are inviting (e.g., bucket list shows "Add your first dream" CTA).
  • Definition of Success: 2Γ—3 grid that collapses to single column on mobile.
  • Odyssey Plan: vertical stack of three cards with colored left edge per path (current = blue, alternative = amber, radical = purple).
  • Future Calendar: two side-by-side cards on desktop, stacked on mobile. Each block is a single row "HH:MM β€” label" with edit-in-place.
  • Autosave indicator: small "Saved" pill that appears after a successful PATCH and fades after 1.5s.

Internationalization

config/locales/en.yml and config/locales/pt-BR.yml:

  • vision.title, vision.subtitle
  • One key per section (title + tagline + placeholder).
  • vision.public, vision.private, vision.saved
  • Definition of Success category labels.
  • Odyssey Plan path labels and tag labels.
  • Future Calendar weekday/weekend labels.

Testing Plan

  • Authenticated user GET /vision renders all six sections with default empty values.
  • Updating each section mutates only its slice and never overwrites other sections.
  • Updating with an unknown section returns 422.
  • Updating with an unknown key inside a known section returns 422.
  • Oversized text returns 422 with a clear error.
  • Public/Private toggle persists per section.
  • Vision#public_sections returns only sections where public: true.
  • Two users do not see each other's vision (owner-only enforcement).
  • Bullet asserts no N+1 on profile page render.
bin/rails test test/controllers/visions_controller_test.rb
bin/rails test test/models/vision_test.rb test/models/vision/serializer_test.rb
bin/ci

Implementation Phases

Phase 1 β€” Model & Storage

  • Migration creates visions table (user_id, content :json, timestamps; unique index on user_id).
  • Vision model with DEFAULT_CONTENT.
  • Vision::Serializer with section schemas and merge logic.
  • Vision::Section value object.

Phase 2 β€” Page Shell & First Section

  • Add /vision route, controller, policy.
  • Build show.html.erb and _section_header.html.erb.
  • Implement _letter_from_100.html.erb end-to-end (autosave + public toggle) so the pattern is locked in.

Phase 3 β€” Remaining Sections

  • Bucket List partial + vision_bucket_list_controller.js.
  • Mission partial.
  • Definition of Success partial.
  • Odyssey Plan partial.
  • Future Calendar partial + vision_future_calendar_controller.js.

Phase 4 β€” Profile Surface

  • Extend Users::ProfilesController#show to render Vision#public_sections.
  • Cache public-section render for 5 minutes per user.

Phase 5 β€” Polish, Security, Performance

  • Confirm strong params + serializer allowlist with negative tests.
  • Add rate limiter.
  • Run Brakeman; review JSON-column write paths for injection.
  • Run bin/ci.

Open Decisions

  • Whether to add server-side rich text (markdown) to the textareas. Initial plan: plain text only, line breaks preserved. Markdown can come later without breaking existing data because the JSON store is opaque.
  • Whether to expose section-level revision history. Initial plan: no β€” Vision changes infrequently; if needed we can record an audits row keyed off Vision#after_update.
  • Whether the bucket list integrates with Goal records (e.g., promote a bucket item to a tracked goal). Initial plan: not in this milestone; can ship as a follow-up "Promote to Goal" button.