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 grouped_by_status && plan.status != previous_status %> +

+ <%= plan.status.titleize %> +

+ <% previous_status = plan.status %> + <% end %> + + <% summary = plan.try(:summary).presence || plan_content_preview(plan) %> + +
+
<%= link_to plan.title, plan_path(plan), class: "plans-list__title", data: { turbo_frame: "_top" } %> <%= plan.status %> <% if plan.plan_type %> @@ -11,22 +21,39 @@ <% if plan_unread > 0 %> <%= plan_unread %> <% end %> -
- <% 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)