diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css
index ff43c8a..d97893f 100644
--- a/engine/app/assets/stylesheets/coplan/application.css
+++ b/engine/app/assets/stylesheets/coplan/application.css
@@ -705,7 +705,7 @@ img.avatar {
text-align: left;
}
-/* Status filters */
+/* Status filters (used on the notifications index) */
.status-filters {
display: flex;
gap: var(--space-sm);
@@ -713,8 +713,34 @@ img.avatar {
flex-wrap: wrap;
}
+/* Plans toolbar (compact filter bar at top of index) */
+.plans-toolbar {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ margin-bottom: var(--space-md);
+}
+
+.plans-toolbar__row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-xs);
+}
+
+.plans-toolbar__row--secondary .status-filter {
+ font-size: 0.7rem;
+}
+
+.plans-toolbar__divider {
+ width: 1px;
+ height: 16px;
+ background: var(--color-border);
+ margin: 0 var(--space-xs);
+}
+
.status-filter {
- padding: var(--space-xs) var(--space-md);
+ padding: 2px var(--space-sm);
font-size: var(--text-sm);
font-weight: 500;
border-radius: 9999px;
@@ -722,6 +748,7 @@ img.avatar {
border: 1px solid var(--color-border);
text-decoration: none;
transition: all 0.15s;
+ line-height: 1.5;
}
.status-filter:hover {
@@ -739,35 +766,80 @@ img.avatar {
.plans-list {
display: flex;
flex-direction: column;
- gap: var(--space-md);
+ gap: var(--space-sm);
+}
+
+.plans-list__item {
+ padding: var(--space-md);
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+}
+
+.plans-list__section {
+ font-size: 0.7rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--color-text-muted);
+ margin: var(--space-sm) 0 0;
+}
+
+.plans-list__section:first-child {
+ margin-top: 0;
}
.plans-list__header {
display: flex;
align-items: center;
- gap: var(--space-sm);
- margin-bottom: var(--space-xs);
+ flex-wrap: wrap;
+ gap: var(--space-xs);
}
.plans-list__title {
- font-size: var(--text-lg);
+ font-size: var(--text-base);
font-weight: 600;
color: var(--color-text);
+ margin-right: var(--space-xs);
}
.plans-list__title:hover {
color: var(--color-primary);
}
-.plans-list__tags {
+.plans-list__summary {
+ margin: 0;
+ font-size: var(--text-sm);
+ color: var(--color-text-muted);
+ line-height: 1.4;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+
+.plans-list__meta {
display: flex;
+ align-items: center;
flex-wrap: wrap;
gap: var(--space-xs);
- margin-top: var(--space-xs);
}
-.plans-list__meta {
- margin-top: var(--space-xs);
+.plans-list__meta-item {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-xs);
+}
+
+.plans-list__meta-item + .plans-list__meta-item::before {
+ content: "·";
+ color: var(--color-text-muted);
+ opacity: 0.5;
+ margin-right: var(--space-xs);
+}
+
+.plans-list__tags {
+ flex-wrap: wrap;
}
.active-filter {
diff --git a/engine/app/controllers/coplan/plans_controller.rb b/engine/app/controllers/coplan/plans_controller.rb
index 19be2eb..ec98693 100644
--- a/engine/app/controllers/coplan/plans_controller.rb
+++ b/engine/app/controllers/coplan/plans_controller.rb
@@ -4,16 +4,30 @@ class PlansController < ApplicationController
PER_PAGE = 20
+ SCOPES = %w[mine all].freeze
+ DEFAULT_SCOPE = "mine".freeze
+
def index
- plans = Plan.includes(:plan_type, :tags, :created_by_user)
- .where.not(status: "brainstorm")
- .or(Plan.where(created_by_user: current_user))
- .order(updated_at: :desc, id: :desc)
+ @scope = SCOPES.include?(params[:scope]) ? params[:scope] : DEFAULT_SCOPE
+
+ plans = Plan.includes(:plan_type, :tags, :created_by_user, :current_plan_version)
+
+ if @scope == "mine"
+ plans = plans.where(created_by_user: current_user)
+ else
+ plans = plans.where.not(status: "brainstorm")
+ .or(Plan.where(created_by_user: current_user))
+ end
+
plans = plans.where(status: params[:status]) if params[:status].present?
- plans = plans.where(created_by_user: current_user) if params[:scope] == "mine"
plans = plans.where(plan_type_id: params[:plan_type]) if params[:plan_type].present?
plans = plans.with_tag(params[:tag]) if params[:tag].present?
+ # Group "My Plans" by status (active → brainstorm) when not already filtered
+ # to a single status. The "All" view stays sorted by recency.
+ @grouped_by_status = @scope == "mine" && params[:status].blank?
+ plans = @grouped_by_status ? plans.prioritized_by_status : plans.order(updated_at: :desc, id: :desc)
+
@page = (params[:page] || 1).to_i
@plans = plans.limit(PER_PAGE + 1).offset((@page - 1) * PER_PAGE)
@has_next_page = @plans.size > PER_PAGE
@@ -25,7 +39,16 @@ def index
.count
if turbo_frame_request?
- render partial: "coplan/plans/plan_page", locals: { plans: @plans, plan_unread_counts: @plan_unread_counts, page: @page, has_next_page: @has_next_page }, layout: false
+ render partial: "coplan/plans/plan_page",
+ locals: {
+ plans: @plans,
+ plan_unread_counts: @plan_unread_counts,
+ page: @page,
+ has_next_page: @has_next_page,
+ grouped_by_status: @grouped_by_status,
+ previous_status: params[:prev_status].presence,
+ },
+ layout: false
else
@plan_types = PlanType.order(:name)
@show_onboarding_banner = CoPlan.configuration.onboarding_banner.present? &&
diff --git a/engine/app/helpers/coplan/plans_helper.rb b/engine/app/helpers/coplan/plans_helper.rb
new file mode 100644
index 0000000..0b751c7
--- /dev/null
+++ b/engine/app/helpers/coplan/plans_helper.rb
@@ -0,0 +1,18 @@
+module CoPlan
+ module PlansHelper
+ include MarkdownHelper
+
+ # Short preview of the plan's content for cards on the index page.
+ # Once an AI summary column lands (COPLAN-24), the card view prefers
+ # `plan.summary` and falls back to this helper.
+ def plan_content_preview(plan, limit: 200)
+ content = plan.current_content
+ return nil if content.blank?
+
+ plain = markdown_to_plain_text(content)
+ return nil if plain.blank?
+
+ truncate(plain, length: limit, omission: "…", separator: " ")
+ end
+ end
+end
diff --git a/engine/app/models/coplan/plan.rb b/engine/app/models/coplan/plan.rb
index 8265f47..2c40506 100644
--- a/engine/app/models/coplan/plan.rb
+++ b/engine/app/models/coplan/plan.rb
@@ -2,6 +2,19 @@ module CoPlan
class Plan < ApplicationRecord
STATUSES = %w[brainstorm considering developing live abandoned].freeze
+ # Order used when grouping "My Plans" on the index: active work first,
+ # brainstorms next, abandoned last.
+ STATUS_PRIORITY = %w[developing live considering brainstorm abandoned].freeze
+
+ # Order plans by STATUS_PRIORITY, then most-recently-updated within each group.
+ scope :prioritized_by_status, -> {
+ whens = STATUS_PRIORITY.each_with_index.map { |status, i|
+ sanitize_sql_array(["WHEN status = ? THEN ?", status, i])
+ }.join(" ")
+ order(Arel.sql("CASE #{whens} ELSE #{STATUS_PRIORITY.length} END"))
+ .order(updated_at: :desc, id: :desc)
+ }
+
belongs_to :created_by_user, class_name: "CoPlan::User"
belongs_to :current_plan_version, class_name: "PlanVersion", optional: true
belongs_to :plan_type, optional: true
diff --git a/engine/app/views/coplan/plans/_plan_page.html.erb b/engine/app/views/coplan/plans/_plan_page.html.erb
index a976b6e..cfcd7a1 100644
--- a/engine/app/views/coplan/plans/_plan_page.html.erb
+++ b/engine/app/views/coplan/plans/_plan_page.html.erb
@@ -1,7 +1,17 @@
<%= turbo_frame_tag "plans-page-#{page}" do %>
+ <% previous_status = local_assigns.fetch(:previous_status, nil) %>
<% plans.each do |plan| %>
-
-
- <% if plan.tags.any? %>
-
- <% plan.tags.each do |tag| %>
- <%= link_to tag.name, plans_path(params.permit(:scope, :status, :plan_type).merge(tag: tag.name)), class: "badge badge--tag #{'badge--tag-active' if params[:tag] == tag.name}", data: { turbo_frame: "_top" } %>
- <% end %>
-
+
+
+ <% if summary.present? %>
+
<%= summary %>
<% end %>
+
- <%= user_avatar(plan.created_by_user) %> <%= plan.created_by_user.name %> · v<%= plan.current_revision %> · updated <%= time_ago_in_words(plan.updated_at) %> ago
+
+ <%= user_avatar(plan.created_by_user) %>
+ <%= plan.created_by_user.name %>
+
+ v<%= plan.current_revision %>
+ updated <%= time_ago_in_words(plan.updated_at) %> ago
+ <% if plan.tags.any? %>
+
+ <% plan.tags.each do |tag| %>
+ <%= link_to tag.name,
+ plans_path(params.permit(:scope, :status, :plan_type).merge(tag: tag.name)),
+ class: "badge badge--tag #{'badge--tag-active' if params[:tag] == tag.name}",
+ data: { turbo_frame: "_top" } %>
+ <% end %>
+
+ <% end %>
-
+
<% end %>
<% if has_next_page %>
- <%= turbo_frame_tag "plans-page-#{page + 1}", src: plans_path(params.permit(:scope, :status, :plan_type, :tag).merge(page: page + 1)), loading: :lazy do %>
+ <% next_page_params = params.permit(:scope, :status, :plan_type, :tag).merge(page: page + 1) %>
+ <% next_page_params[:prev_status] = plans.last.status if grouped_by_status && plans.any? %>
+ <%= turbo_frame_tag "plans-page-#{page + 1}",
+ src: plans_path(next_page_params),
+ loading: :lazy do %>
Loading more plans…
<% end %>
<% end %>
diff --git a/engine/app/views/coplan/plans/index.html.erb b/engine/app/views/coplan/plans/index.html.erb
index c022475..58a34f7 100644
--- a/engine/app/views/coplan/plans/index.html.erb
+++ b/engine/app/views/coplan/plans/index.html.erb
@@ -1,41 +1,53 @@
-
+
+
+ <%= link_to "Mine", plans_path(params.permit(:status, :plan_type, :tag)),
+ class: "status-filter #{'status-filter--active' if @scope == 'mine'}" %>
+ <%= link_to "All", plans_path(params.permit(:status, :plan_type, :tag).merge(scope: "all")),
+ class: "status-filter #{'status-filter--active' if @scope == 'all'}" %>
+
+ <%= link_to "Any status", plans_path(params.permit(:scope, :plan_type, :tag)),
+ class: "status-filter #{'status-filter--active' if params[:status].blank?}" %>
+ <% CoPlan::Plan::STATUSES.each do |status| %>
+ <%= link_to status.titleize, plans_path(params.permit(:scope, :plan_type, :tag).merge(status: status)),
+ class: "status-filter status-filter--#{status} #{'status-filter--active' if params[:status] == status}" %>
+ <% end %>
+
-
- <%= link_to "All Plans", plans_path(params.permit(:status, :tag)), class: "status-filter #{'status-filter--active' if params[:scope].blank?}" %>
- <%= link_to "My Plans", plans_path(params.permit(:status, :tag).merge(scope: "mine")), class: "status-filter #{'status-filter--active' if params[:scope] == 'mine'}" %>
-
+ <% if @plan_types.any? %>
+
+ <%= link_to "Any type", plans_path(params.permit(:scope, :status, :tag)),
+ class: "status-filter #{'status-filter--active' if params[:plan_type].blank?}" %>
+ <% @plan_types.each do |pt| %>
+ <%= link_to pt.name, plans_path(params.permit(:scope, :status, :tag).merge(plan_type: pt.id)),
+ class: "status-filter #{'status-filter--active' if params[:plan_type] == pt.id}" %>
+ <% end %>
+
+ <% end %>
-
- <%= link_to "All", plans_path(params.permit(:scope, :plan_type, :tag)), class: "status-filter #{'status-filter--active' if params[:status].blank?}" %>
- <% CoPlan::Plan::STATUSES.each do |status| %>
- <%= link_to status.titleize, plans_path(params.permit(:scope, :plan_type, :tag).merge(status: status)), class: "status-filter status-filter--#{status} #{'status-filter--active' if params[:status] == status}" %>
+ <% if params[:tag].present? %>
+
+ Filtered by tag: <%= params[:tag] %>
+ <%= link_to "✕ Clear", plans_path(params.permit(:scope, :status, :plan_type)), class: "active-filter__clear" %>
+
<% end %>
-<% if @plan_types.any? %>
-
- <%= link_to "All Types", plans_path(params.permit(:scope, :status, :tag)), class: "status-filter #{'status-filter--active' if params[:plan_type].blank?}" %>
- <% @plan_types.each do |pt| %>
- <%= link_to pt.name, plans_path(params.permit(:scope, :status, :tag).merge(plan_type: pt.id)), class: "status-filter #{'status-filter--active' if params[:plan_type] == pt.id}" %>
- <% end %>
-
-<% end %>
-
-<% if params[:tag].present? %>
-
- Filtered by tag: <%= params[:tag] %>
- <%= link_to "✕ Clear", plans_path(params.permit(:scope, :status, :plan_type)), class: "active-filter__clear" %>
-
-<% end %>
-
<% if @plans.any? %>
- <%= render partial: "coplan/plans/plan_page", locals: { plans: @plans, plan_unread_counts: @plan_unread_counts, page: @page, has_next_page: @has_next_page } %>
+ <%= render partial: "coplan/plans/plan_page", locals: {
+ plans: @plans,
+ plan_unread_counts: @plan_unread_counts,
+ page: @page,
+ has_next_page: @has_next_page,
+ grouped_by_status: @grouped_by_status,
+ } %>
<% else %>
-
No plans yet. Plans are created via the API.
+ <% if @scope == "mine" %>
+
You haven't created any plans yet. Plans are created via the API.
+ <% else %>
+
No plans yet. Plans are created via the API.
+ <% end %>
<% end %>
diff --git a/engine/app/views/coplan/welcome/_default_landing.html.erb b/engine/app/views/coplan/welcome/_default_landing.html.erb
index c0a1619..533b376 100644
--- a/engine/app/views/coplan/welcome/_default_landing.html.erb
+++ b/engine/app/views/coplan/welcome/_default_landing.html.erb
@@ -63,7 +63,7 @@
<% end %>
diff --git a/spec/factories/plans.rb b/spec/factories/plans.rb
index c3ffb0e..fa64024 100644
--- a/spec/factories/plans.rb
+++ b/spec/factories/plans.rb
@@ -20,5 +20,17 @@
trait :brainstorm do
status { "brainstorm" }
end
+
+ trait :developing do
+ status { "developing" }
+ end
+
+ trait :live do
+ status { "live" }
+ end
+
+ trait :abandoned do
+ status { "abandoned" }
+ end
end
end
diff --git a/spec/helpers/plans_helper_spec.rb b/spec/helpers/plans_helper_spec.rb
new file mode 100644
index 0000000..0857b41
--- /dev/null
+++ b/spec/helpers/plans_helper_spec.rb
@@ -0,0 +1,37 @@
+require "rails_helper"
+
+RSpec.describe CoPlan::PlansHelper, type: :helper do
+ describe "#plan_content_preview" do
+ let(:plan) { create(:plan, :considering) }
+
+ it "strips markdown formatting and returns a plain-text preview" do
+ plan.current_plan_version.update!(
+ content_markdown: "# Heading\n\nA **bold** intro with [a link](https://example.com)."
+ )
+ preview = helper.plan_content_preview(plan)
+ expect(preview).to include("Heading")
+ expect(preview).to include("A bold intro with a link")
+ expect(preview).not_to include("**")
+ expect(preview).not_to include("](")
+ end
+
+ it "truncates to the requested limit" do
+ plan.current_plan_version.update!(content_markdown: "word " * 100)
+ preview = helper.plan_content_preview(plan, limit: 40)
+ expect(preview.length).to be <= 41 # 40 chars + ellipsis
+ expect(preview).to end_with("…")
+ end
+
+ it "returns nil when the plan has no content" do
+ plan.current_plan_version.update_columns(content_markdown: "", content_sha256: Digest::SHA256.hexdigest(""))
+ plan.reload
+ expect(helper.plan_content_preview(plan)).to be_nil
+ end
+
+ it "returns nil when the plan has no version" do
+ plan.update_columns(current_plan_version_id: nil)
+ plan.reload
+ expect(helper.plan_content_preview(plan)).to be_nil
+ end
+ end
+end
diff --git a/spec/requests/plans_spec.rb b/spec/requests/plans_spec.rb
index 7233aad..2048924 100644
--- a/spec/requests/plans_spec.rb
+++ b/spec/requests/plans_spec.rb
@@ -139,10 +139,10 @@
expect(response).to redirect_to(plan_path(plan))
end
- it "index hides other users brainstorm plans" do
+ it "index hides other users brainstorm plans on the All scope" do
brainstorm_plan # alice's brainstorm
sign_in_as(bob)
- get plans_path
+ get plans_path(scope: "all")
expect(response).to have_http_status(:success)
expect(response.body).not_to include(brainstorm_plan.title)
end
@@ -154,6 +154,64 @@
expect(response.body).to include(brainstorm_plan.title)
end
+ describe "default scope" do
+ it "defaults to 'mine' and hides other users' plans" do
+ plan # alice's
+ bobs_plan = create(:plan, :considering, created_by_user: bob, title: "Bobs Roadmap")
+ get plans_path
+ expect(response.body).to include(plan.title)
+ expect(response.body).not_to include(bobs_plan.title)
+ end
+
+ it "scope=all shows everyone's published plans" do
+ plan # alice's
+ bobs_plan = create(:plan, :considering, created_by_user: bob, title: "Bobs Roadmap")
+ get plans_path(scope: "all")
+ expect(response.body).to include(plan.title)
+ expect(response.body).to include(bobs_plan.title)
+ end
+
+ it "groups My Plans by status with section headers" do
+ create(:plan, :developing, created_by_user: alice, title: "Developing Plan")
+ create(:plan, :considering, created_by_user: alice, title: "Considering Plan")
+ create(:plan, :brainstorm, created_by_user: alice, title: "Brainstorm Plan")
+ get plans_path
+ expect(response.body).to include("plans-list__section")
+ # active work appears before brainstorm
+ expect(response.body.index("Developing Plan")).to be < response.body.index("Brainstorm Plan")
+ expect(response.body.index("Considering Plan")).to be < response.body.index("Brainstorm Plan")
+ end
+
+ it "does not group when filtered to a single status" do
+ create(:plan, :developing, created_by_user: alice, title: "Developing Plan")
+ get plans_path(status: "developing")
+ expect(response.body).not_to include("plans-list__section")
+ expect(response.body).to include("Developing Plan")
+ end
+
+ it "scope=all does not group by status" do
+ create(:plan, :developing, created_by_user: alice, title: "Developing Plan")
+ get plans_path(scope: "all")
+ expect(response.body).not_to include("plans-list__section")
+ end
+ end
+
+ describe "content preview on cards" do
+ it "renders a markdown-stripped preview when there is no AI summary" do
+ plan.current_plan_version.update!(content_markdown: "# Heading\n\nThis is the **plan body** with [links](https://example.com).")
+ get plans_path
+ expect(response.body).to include("plans-list__summary")
+ expect(response.body).to include("This is the plan body with links")
+ expect(response.body).not_to include("**plan body**")
+ end
+
+ it "omits the summary block when the plan has no content" do
+ plan.current_plan_version.update_columns(content_markdown: "", content_sha256: Digest::SHA256.hexdigest(""))
+ get plans_path
+ expect(response.body).not_to include("plans-list__summary")
+ end
+ end
+
it "can view brainstorm plan as non-author" do
sign_in_as(bob)
get plan_path(brainstorm_plan)