Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 82 additions & 10 deletions engine/app/assets/stylesheets/coplan/application.css
Original file line number Diff line number Diff line change
Expand Up @@ -705,23 +705,50 @@ img.avatar {
text-align: left;
}

/* Status filters */
/* Status filters (used on the notifications index) */
.status-filters {
display: flex;
gap: var(--space-sm);
margin-bottom: var(--space-xl);
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;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
text-decoration: none;
transition: all 0.15s;
line-height: 1.5;
}

.status-filter:hover {
Expand All @@ -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 {
Expand Down
35 changes: 29 additions & 6 deletions engine/app/controllers/coplan/plans_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,30 @@ class PlansController < ApplicationController

PER_PAGE = 20

SCOPES = %w[mine all].freeze
DEFAULT_SCOPE = "mine".freeze
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve all-scope links that omit scope

When a signed-in user clicks the existing landing-page link labeled Browse all plans →, it still calls plans_path without a scope (checked engine/app/views/coplan/welcome/_default_landing.html.erb:66), but this new default now routes that unscoped request to only the user's own plans. That makes a link explicitly promising all plans silently hide everyone else's published plans; update that caller (and any similar all-browse entry points) to pass scope: "all" or avoid changing the unscoped default for those paths.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — fixed in a124a65. The 'Browse all plans →' link now passes scope: "all" explicitly so it keeps its promise. The primary 'Browse plans' CTA at the top stays unscoped (defaults to mine), which feels right for a signed-in user landing.


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
Expand All @@ -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? &&
Expand Down
18 changes: 18 additions & 0 deletions engine/app/helpers/coplan/plans_helper.rb
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions engine/app/models/coplan/plan.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 39 additions & 12 deletions engine/app/views/coplan/plans/_plan_page.html.erb
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
<%= turbo_frame_tag "plans-page-#{page}" do %>
<% previous_status = local_assigns.fetch(:previous_status, nil) %>
<% plans.each do |plan| %>
<div class="card plans-list__item">
<div class="plans-list__header">
<% if grouped_by_status && plan.status != previous_status %>
<h2 class="plans-list__section plans-list__section--<%= plan.status %>">
<%= plan.status.titleize %>
</h2>
<% previous_status = plan.status %>
<% end %>

<% summary = plan.try(:summary).presence || plan_content_preview(plan) %>

<article class="card plans-list__item">
<header class="plans-list__header">
<%= link_to plan.title, plan_path(plan), class: "plans-list__title", data: { turbo_frame: "_top" } %>
<span class="badge badge--<%= plan.status %>"><%= plan.status %></span>
<% if plan.plan_type %>
Expand All @@ -11,22 +21,39 @@
<% if plan_unread > 0 %>
<span class="inbox-badge"><%= plan_unread %></span>
<% end %>
</div>
<% if plan.tags.any? %>
<div class="plans-list__tags">
<% 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 %>
</div>
</header>

<% if summary.present? %>
<p class="plans-list__summary"><%= summary %></p>
<% end %>

<div class="plans-list__meta text-sm text-muted">
<%= user_avatar(plan.created_by_user) %> <%= plan.created_by_user.name %> · v<%= plan.current_revision %> · updated <%= time_ago_in_words(plan.updated_at) %> ago
<span class="plans-list__meta-item plans-list__meta-item--author">
<%= user_avatar(plan.created_by_user) %>
<%= plan.created_by_user.name %>
</span>
<span class="plans-list__meta-item">v<%= plan.current_revision %></span>
<span class="plans-list__meta-item">updated <%= time_ago_in_words(plan.updated_at) %> ago</span>
<% if plan.tags.any? %>
<span class="plans-list__meta-item plans-list__tags">
<% 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 %>
</span>
<% end %>
</div>
</div>
</article>
<% 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 %>
<div class="plans-list__loading text-muted text-sm">Loading more plans…</div>
<% end %>
<% end %>
Expand Down
70 changes: 41 additions & 29 deletions engine/app/views/coplan/plans/index.html.erb
Original file line number Diff line number Diff line change
@@ -1,41 +1,53 @@
<div class="page-header">
<h1>Plans</h1>
</div>
<div class="plans-toolbar">
<div class="plans-toolbar__row">
<%= 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'}" %>
<span class="plans-toolbar__divider" aria-hidden="true"></span>
<%= 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 %>
</div>

<div class="status-filters">
<%= 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'}" %>
</div>
<% if @plan_types.any? %>
<div class="plans-toolbar__row plans-toolbar__row--secondary">
<%= 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 %>
</div>
<% end %>

<div class="status-filters">
<%= 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? %>
<div class="active-filter">
Filtered by tag: <span class="badge badge--tag badge--tag-active"><%= params[:tag] %></span>
<%= link_to "✕ Clear", plans_path(params.permit(:scope, :status, :plan_type)), class: "active-filter__clear" %>
</div>
<% end %>
</div>

<% if @plan_types.any? %>
<div class="status-filters">
<%= 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 %>
</div>
<% end %>

<% if params[:tag].present? %>
<div class="active-filter">
Filtered by tag: <span class="badge badge--tag badge--tag-active"><%= params[:tag] %></span>
<%= link_to "✕ Clear", plans_path(params.permit(:scope, :status, :plan_type)), class: "active-filter__clear" %>
</div>
<% end %>

<% if @plans.any? %>
<div class="plans-list">
<%= 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,
} %>
</div>
<% else %>
<div class="empty-state card">
<p>No plans yet. Plans are created via the API.</p>
<% if @scope == "mine" %>
<p>You haven't created any plans yet. Plans are created via the API.</p>
<% else %>
<p>No plans yet. Plans are created via the API.</p>
<% end %>
</div>
<% end %>
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@
<footer class="landing__footer">
<p>
Signed in as <strong><%= current_user.name %></strong>.
<%= link_to "Browse all plans →", plans_path, class: "landing__inline-link" %>
<%= link_to "Browse all plans →", plans_path(scope: "all"), class: "landing__inline-link" %>
</p>
</footer>
<% end %>
Expand Down
Loading
Loading