diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 000000000..ba24bdab3
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,10 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(git merge:*)",
+ "Bash(git add:*)"
+ ],
+ "deny": [],
+ "ask": []
+ }
+}
\ No newline at end of file
diff --git a/Gemfile b/Gemfile
index c5ac64538..2778cbdb0 100644
--- a/Gemfile
+++ b/Gemfile
@@ -40,10 +40,13 @@ gem "sentry-ruby"
gem "sentry-rails"
gem "rails_structured_logging", bc: "rails-structured-logging"
gem "yabeda"
-gem "yabeda-rails"
-gem "yabeda-puma-plugin"
-gem "webrick" # required for yabeda-prometheus metrics server
+gem "yabeda-activejob", github: "rosa/yabeda-activejob", branch: "support-end-time-in-seconds"
+gem "yabeda-gc"
+gem "yabeda-http_requests"
gem "yabeda-prometheus-mmap"
+gem "yabeda-puma-plugin"
+gem "yabeda-rails"
+gem "webrick" # required for yabeda-prometheus metrics server
gem "prometheus-client-mmap", "~> 1.1"
# AI
diff --git a/Gemfile.lock b/Gemfile.lock
index 4d38c63d0..243cc1489 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -197,6 +197,15 @@ GIT
tsort (>= 0.2)
zeitwerk (~> 2.6)
+GIT
+ remote: https://github.com/rosa/yabeda-activejob.git
+ revision: 08207e7dddf6f4dd1d039c1ddf792b35655a3feb
+ branch: support-end-time-in-seconds
+ specs:
+ yabeda-activejob (0.5.0)
+ rails (>= 5.2)
+ yabeda (~> 0.6)
+
PATH
remote: gems/fizzy-saas
specs:
@@ -215,9 +224,9 @@ GEM
activemodel (>= 5.0.0.a)
activesupport (>= 5.0.0.a)
builder (~> 3.1)
- activerecord-tenanted (0.3.0)
- activerecord (>= 8.1.alpha)
- railties (>= 8.1.alpha)
+ activerecord-tenanted (0.4.1)
+ activerecord (>= 8.1.beta)
+ railties (>= 8.1.beta)
zeitwerk
activeresource (6.2.0)
activemodel (>= 7.0)
@@ -548,6 +557,9 @@ GEM
sentry-ruby (5.27.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
+ sniffer (0.5.0)
+ anyway_config (>= 1.0)
+ dry-initializer (~> 3)
solid_cable (3.0.12)
actioncable (>= 7.2)
activejob (>= 7.2)
@@ -569,11 +581,11 @@ GEM
sqlite-vec (0.1.7.alpha.2-x86_64-darwin)
sqlite-vec (0.1.7.alpha.2-x86_64-linux)
sqlite-vec (0.1.7.alpha.2-x86_64-mingw32)
- sqlite3 (2.7.3)
+ sqlite3 (2.7.4)
mini_portile2 (~> 2.8.0)
- sqlite3 (2.7.3-arm64-darwin)
- sqlite3 (2.7.3-x86_64-darwin)
- sqlite3 (2.7.3-x86_64-linux-gnu)
+ sqlite3 (2.7.4-arm64-darwin)
+ sqlite3 (2.7.4-x86_64-darwin)
+ sqlite3 (2.7.4-x86_64-linux-gnu)
sshkit (1.24.0)
base64
logger
@@ -627,6 +639,11 @@ GEM
anyway_config (>= 1.0, < 3)
concurrent-ruby
dry-initializer
+ yabeda-gc (0.4.0)
+ yabeda (~> 0.6)
+ yabeda-http_requests (0.2.1)
+ sniffer
+ yabeda
yabeda-prometheus-mmap (0.4.0)
prometheus-client-mmap
yabeda (~> 0.10)
@@ -704,6 +721,9 @@ DEPENDENCIES
webmock
webrick
yabeda
+ yabeda-activejob!
+ yabeda-gc
+ yabeda-http_requests
yabeda-prometheus-mmap
yabeda-puma-plugin
yabeda-rails
diff --git a/app/assets/images/close.svg b/app/assets/images/close.svg
index 878343d21..c3066ce69 100644
--- a/app/assets/images/close.svg
+++ b/app/assets/images/close.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/assets/stylesheets/_global.css b/app/assets/stylesheets/_global.css
index 7735be1f7..0f6c4c2c1 100644
--- a/app/assets/stylesheets/_global.css
+++ b/app/assets/stylesheets/_global.css
@@ -326,7 +326,7 @@
--color-terminal-bg: var(--color-canvas);
--color-terminal-text-light: oklch(var(--lch-green-dark));
- --color-golden: oklch(var(--lch-blue-light));
+ --color-golden: oklch(var(--lch-blue-medium));
--shadow: 0 0 0 1px oklch(var(--lch-black) / 0.42),
0 .2em 1.6em -0.8em oklch(var(--lch-black) / 0.6),
diff --git a/app/assets/stylesheets/bar.css b/app/assets/stylesheets/bar.css
index 8370ef900..be5eb6a2e 100644
--- a/app/assets/stylesheets/bar.css
+++ b/app/assets/stylesheets/bar.css
@@ -3,6 +3,7 @@
--row-gap: 0.2lh;
background-color: var(--color-terminal-bg);
+ block-size: var(--footer-height);
color: var(--color-terminal-text);
display: flex;
flex-direction: column;
@@ -17,14 +18,18 @@
@media (prefers-color-scheme: dark) {
border-block: 1px solid var(--color-ink-lighter);
}
+
+ &:has(.bar__placeholder[hidden]) {
+ padding-inline: 1ch;
+ }
}
.bar__input {
- transform: translateX(50%);
+ transform: translateY(50%);
transition: transform 350ms cubic-bezier(0.25, 1.25, 0.5, 1);
.bar:has(.bar__placeholder[hidden]) & {
- transform: translateX(0);
+ transform: translateY(0);
}
}
diff --git a/app/assets/stylesheets/bubble.css b/app/assets/stylesheets/bubble.css
index 132e10b84..e334b8582 100644
--- a/app/assets/stylesheets/bubble.css
+++ b/app/assets/stylesheets/bubble.css
@@ -54,7 +54,7 @@
}
.bubble--doing,
- .bubble--considering {
+ .bubble--on_deck {
inset-inline-start: 100%;
translate: -60% -60%;
diff --git a/app/assets/stylesheets/buttons.css b/app/assets/stylesheets/buttons.css
index b8a30c7f6..11e52668c 100644
--- a/app/assets/stylesheets/buttons.css
+++ b/app/assets/stylesheets/buttons.css
@@ -64,6 +64,7 @@
.btn[aria-label]:where(:has(.icon)),
.btn:where(:has(.for-screen-reader):has(.icon)) {
--btn-padding: 0;
+ --icon-size: 75%;
aspect-ratio: 1;
block-size: var(--btn-size);
diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css
index 26d3c3ede..5d36ad6c0 100644
--- a/app/assets/stylesheets/card-columns.css
+++ b/app/assets/stylesheets/card-columns.css
@@ -282,11 +282,13 @@
/* ------------------------------------------------------------------------ */
.cards--considering {
+ --gradient-opacity: 25%;
+
background-color: var(--color-canvas);
background-image: linear-gradient(90deg,
transparent 0%,
- color-mix(in srgb, var(--color-considering) 25%, transparent) 33%,
- color-mix(in srgb, var(--color-considering) 25%, transparent) 66%,
+ color-mix(in srgb, var(--color-considering) var(--gradient-opacity), transparent) 33%,
+ color-mix(in srgb, var(--color-considering) var(--gradient-opacity), transparent) 66%,
transparent 100%);
@supports(mask: radial-gradient(black)) {
@@ -305,6 +307,51 @@
}
}
+ @media (min-width: 640px) {
+ padding-inline: var(--cards-gap);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ --gradient-opacity: 10%;
+ }
+
+ .cards__decoration {
+ --gradient-opacity: 30%;
+ --size: 4.25em;
+
+ background-color: var(--color-canvas);
+ background-image: radial-gradient(ellipse at bottom,
+ color-mix(in srgb, var(--color-considering) var(--gradient-opacity), transparent) 0%,
+ transparent 70%);
+ block-size: var(--size);
+ inline-size: 100%;
+ inset: calc(var(--size) * -1) 0 auto 0;
+ position: absolute;
+ z-index: -1;
+
+ @media (prefers-color-scheme: dark) {
+ --gradient-opacity: 15%;
+ }
+
+ @supports(mask: radial-gradient(black)) {
+ &:before {
+ background: radial-gradient(ellipse at bottom,
+ color-mix(in srgb, var(--color-considering) var(--gradient-opacity), transparent) 0%,
+ transparent 70%);
+ content: "";
+ inset: 0;
+ mask: radial-gradient(6px at 50% calc(100% + 3px), transparent calc(99% - 2px), black calc(101% - 2px) 99%, transparent 101%) calc(50% - 8px) calc(50% - 3px + .5px)/16px 6px,
+ radial-gradient(6px at 50% -3px, transparent calc(99% - 2px), black calc(101% - 2px) 99%, transparent 101%) 50% calc(50% + 3px)/16px 6px;
+ position: absolute;
+ z-index: 0;
+ }
+ }
+
+ &.cards__decoration--end {
+ inset: auto 0 calc(var(--size) * -1);
+ rotate: 180deg;
+ }
+ }
.card {
--avatar-size: 2.75em;
--text-small: 1.1em;
@@ -364,38 +411,6 @@
}
}
- .cards__decoration {
- --size: 4.25em;
-
- background-color: var(--color-canvas);
- background-image: radial-gradient(ellipse at bottom,
- color-mix(in srgb, var(--color-considering) 30%, transparent) 0%,
- transparent 70%);
- block-size: var(--size);
- inline-size: 100%;
- inset: calc(var(--size) * -1) 0 auto 0;
- position: absolute;
- z-index: -1;
-
- @supports(mask: radial-gradient(black)) {
- &:before {
- background: radial-gradient(ellipse at bottom,
- color-mix(in srgb, var(--color-considering) 30%, transparent) 0%,
- transparent 70%);
- content: "";
- inset: 0;
- mask: radial-gradient(6px at 50% calc(100% + 3px), transparent calc(99% - 2px), black calc(101% - 2px) 99%, transparent 101%) calc(50% - 8px) calc(50% - 3px + .5px)/16px 6px,
- radial-gradient(6px at 50% -3px, transparent calc(99% - 2px), black calc(101% - 2px) 99%, transparent 101%) 50% calc(50% + 3px)/16px 6px;
- position: absolute;
- z-index: 0;
- }
- }
-
- &.cards__decoration--end {
- inset: auto 0 calc(var(--size) * -1);
- rotate: 180deg;
- }
- }
.card.card--new {
--border-color: var(--color-selected-dark);
diff --git a/app/assets/stylesheets/filters.css b/app/assets/stylesheets/filters.css
index f5ad4dc34..db259ee7c 100644
--- a/app/assets/stylesheets/filters.css
+++ b/app/assets/stylesheets/filters.css
@@ -13,7 +13,6 @@
}
.filters {
-
.btn {
--btn-border-color: var(--color-ink-medium);
--input-background: var(--color-canvas);
@@ -111,7 +110,15 @@
}
.quick-filter {
- &:not(.default-value):has(.checked) {
+ .checked {
+ display: none;
+ }
+
+ [aria-checked="true"] .checked {
+ display: block;
+ }
+
+ &:has([aria-checked="true"]):not(.quick-filter--with-default) {
.input--select {
--input-background: var(--color-selected);
}
@@ -133,5 +140,13 @@
display: block;
}
}
+
+ .filters__manage {
+ display: none;
+ }
+
+ .filters--has-filters-set .filters__manage {
+ display: flex;
+ }
}
diff --git a/app/assets/stylesheets/lexxy.css b/app/assets/stylesheets/lexxy.css
index eb19c788a..f2dd41d2e 100644
--- a/app/assets/stylesheets/lexxy.css
+++ b/app/assets/stylesheets/lexxy.css
@@ -88,6 +88,8 @@
}
lexxy-toolbar {
+ --lexxy-toolbar-icon-size: 1em;
+
background-color: inherit;
border-block-end: 1px solid var(--color-ink-light);
color: currentColor;
@@ -112,48 +114,60 @@
min-inline-size: 30ch;
}
}
+ }
- > button {
- aspect-ratio: 4/3.5;
- appearance: none;
- background-color: transparent;
- block-size: 2em;
- border: none;
- border-radius: 0.2em;
- color: currentColor;
- cursor: pointer;
- display: grid;
- font-size: inherit;
- inline-size: auto;
- place-items: center;
- transition: background-color 300ms ease;
+ .lexxy-editor__toolbar-button {
+ --toolbar-button-size: 44px;
- svg {
- -webkit-touch-callout: none;
- block-size: 0.85em;
- fill: currentColor;
- grid-area: 1/1;
- inline-size: auto;
- user-select: none;
- }
+ appearance: none;
+ aspect-ratio: 1;
+ background-color: transparent;
+ block-size: var(--toolbar-button-size);
+ border: none;
+ border-radius: 0.2em;
+ color: currentColor;
+ cursor: pointer;
+ display: grid;
+ font-size: inherit;
+ place-items: center;
- &:is(:focus, :hover) {
- background-color: var(--color-ink-lighter);
- box-shadow: none;
- }
+ &:is(:focus, :hover) {
+ background-color: var(--color-ink-lighter);
+ box-shadow: none;
+ }
- &:is(:active) {
- background-color: var(--color-selected);
- }
+ &:is(:active),
+ &[aria-pressed="true"] {
+ background-color: var(--color-selected);
+ }
- &[aria-pressed="true"] {
- background-color: var(--color-selected);
- }
+ svg {
+ -webkit-touch-callout: none;
+ block-size: var(--lexxy-toolbar-icon-size);
+ fill: currentColor;
+ grid-area: 1/1;
+ inline-size: var(--lexxy-toolbar-icon-size);
+ user-select: none;
+ }
+
+ @media (min-width: 640px) {
+ --toolbar-button-size: 2em;
}
}
+ .lexxy-editor__toolbar-overflow-menu {
+ background-color: var(--color-canvas);
+ border-radius: 0.5ch;
+ box-shadow: var(--shadow);
+ display: flex;
+ inset-inline-end: 0;
+ padding: 4px;
+ position: absolute;
+ z-index: 1;
+ }
+
+ /* Based on .input, .input--select */
.lexxy-code-language-picker {
- /* Based on .input, .input--select */
-webkit-appearance: none;
appearance: none;
background-color: var(--color-canvas);
diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css
index c6035dedd..c864655a4 100644
--- a/app/assets/stylesheets/popup.css
+++ b/app/assets/stylesheets/popup.css
@@ -46,7 +46,8 @@
/* Hide lists when all the items within are hidden */
.popup__section {
- &:has(.popup__section-title:only-child),
+ &:not(:has(.popup__list)),
+ &:not(:has(.popup__list > *)),
&:has(.popup__item[hidden]):not(:has(.popup__item:not([hidden]))) {
display: none;
}
diff --git a/app/assets/stylesheets/reset.css b/app/assets/stylesheets/reset.css
index e224c8cf9..959a6e60b 100644
--- a/app/assets/stylesheets/reset.css
+++ b/app/assets/stylesheets/reset.css
@@ -71,9 +71,18 @@
cursor: pointer;
}
+ summary {
+ &::-webkit-details-marker {
+ display: none;
+ }
+
+ &::marker {
+ content: "";
+ }
+ }
+
/* Remove all animations and transitions for people that prefer not to see them */
@media (prefers-reduced-motion: reduce) {
-
*,
*::before,
*::after {
diff --git a/app/assets/stylesheets/search.css b/app/assets/stylesheets/search.css
index 49e466b39..371ed21fd 100644
--- a/app/assets/stylesheets/search.css
+++ b/app/assets/stylesheets/search.css
@@ -1,4 +1,20 @@
+summary {
+
+ &::-webkit-details-marker {
+ display: none !important;
+ }
+
+ &::marker {
+ display: none !important;
+ }
+
+ &::marker {
+ content: "";
+ }
+}
+
@layer components {
+
.search {
--gap: 4vmin;
--width: 80ch;
@@ -16,7 +32,7 @@
/* Form
/* ------------------------------------------------------------------------ */
- .search__form-input {
+ .search__input {
--clear-icon-size: 1em;
--focus-ring-size: 0;
--input-border-color: var(--color-ink-light);
@@ -28,26 +44,22 @@
position: relative;
&::-webkit-search-cancel-button {
- --clear-icon-size: 0.66rem;
+ display: none;
+ }
+ }
- -webkit-appearance: none;
- appearance: none;
- background-image: url('data:image/svg+xml;utf8,');
- background-repeat: no-repeat;
- background-size: var(--clear-icon-size);
- block-size: var(--clear-icon-size);
- cursor: pointer;
- inline-size: var(--clear-icon-size);
- inset: 50% 0 auto auto;
- position: absolute;
- translate: 0 -50%;
+ .search__reset {
+ --btn-background: var(--color-terminal-bg);
+ --btn-size: 1.25lh;
- @media (prefers-color-scheme: dark) {
- background-image: url('data:image/svg+xml;utf8,');
+ @media (min-width: 800px) {
+ .search__input:placeholder-shown ~ & {
+ opacity: 0;
}
}
}
+
/* Content
/* ------------------------------------------------------------------------ */
diff --git a/app/assets/stylesheets/settings.css b/app/assets/stylesheets/settings.css
index 9baa51b14..15b7b0d06 100644
--- a/app/assets/stylesheets/settings.css
+++ b/app/assets/stylesheets/settings.css
@@ -21,6 +21,7 @@
flex-direction: column;
gap: var(--settings-spacer);
min-block-size: 100%;
+ min-inline-size: 0;
@media (min-width: 960px) {
--panel-padding: calc(var(--settings-spacer) * 2);
diff --git a/app/controllers/cards/drops_controller.rb b/app/controllers/cards/drops_controller.rb
index 6bb10c562..007580a3f 100644
--- a/app/controllers/cards/drops_controller.rb
+++ b/app/controllers/cards/drops_controller.rb
@@ -1,5 +1,6 @@
class Cards::DropsController < ApplicationController
- include Collections::ColumnsScoped
+ include FilterScoped
+
before_action :set_card, :set_drop_target
def create
@@ -40,7 +41,8 @@ class Cards::DropsController < ApplicationController
end
def render_column_replacement
- page_and_filter = page_and_filter_for @filter.with(engagement_status: @drop_target.to_s), per_page: CardsController::PAGE_SIZE
+ columns = Cards::Columns.new(user_filtering: @user_filtering, page_size: CardsController::PAGE_SIZE)
+ column = columns.public_send(@drop_target)
if @drop_target == :doing && params[:stage_id].present?
# For stage-specific doing columns, we need to render the specific stage
@@ -49,14 +51,14 @@ class Cards::DropsController < ApplicationController
turbo_stream: turbo_stream.replace("doing-cards-#{stage.id}",
method: :morph,
partial: "cards/index/engagement/doing",
- locals: { user_filtering: @user_filtering, **page_and_filter.to_h, stage: stage })
+ locals: { column: column, stage: stage })
else
# For other columns, use the standard approach
render \
turbo_stream: turbo_stream.replace("#{@drop_target.to_s.gsub('_', '-')}-cards",
method: :morph,
partial: "cards/index/engagement/#{@drop_target}",
- locals: { user_filtering: @user_filtering, **page_and_filter.to_h })
+ locals: { column: column })
end
end
end
diff --git a/app/controllers/cards_controller.rb b/app/controllers/cards_controller.rb
index 1bdd201c0..e30867991 100644
--- a/app/controllers/cards_controller.rb
+++ b/app/controllers/cards_controller.rb
@@ -1,7 +1,7 @@
require "ostruct"
class CardsController < ApplicationController
- include Collections::ColumnsScoped
+ include FilterScoped
before_action :set_collection, only: %i[ create ]
before_action :set_card, only: %i[ show edit update destroy ]
@@ -11,13 +11,9 @@ class CardsController < ApplicationController
PAGE_SIZE = 25
def index
- @considering = page_and_filter_for @filter.with(engagement_status: "considering"), per_page: PAGE_SIZE
- @on_deck = page_and_filter_for @filter.with(engagement_status: "on_deck"), per_page: PAGE_SIZE
- @doing = page_and_filter_for @filter.with(engagement_status: "doing"), per_page: PAGE_SIZE
- @closed = page_and_filter_for_closed_cards
+ @columns = Cards::Columns.new(user_filtering: @user_filtering, page_size: PAGE_SIZE)
- @cache_key = [ @considering, @on_deck, @doing, @closed ].collect { it.page.records }.including([ Workflow.all, @user_filtering ])
- fresh_when etag: @cache_key
+ fresh_when etag: @columns
end
def create
@@ -26,7 +22,7 @@ class CardsController < ApplicationController
end
def show
- fresh_when @card
+ fresh_when etag: @card.cache_invalidation_parts.for_perma
end
def edit
diff --git a/app/controllers/concerns/collections/columns_scoped.rb b/app/controllers/concerns/collections/columns_scoped.rb
deleted file mode 100644
index a588a15b6..000000000
--- a/app/controllers/concerns/collections/columns_scoped.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-module Collections::ColumnsScoped
- extend ActiveSupport::Concern
-
- included do
- include FilterScoped
- end
-
- def page_and_filter_for(filter, per_page: nil)
- cards = block_given? ? yield(filter.cards) : filter.cards
-
- OpenStruct.new \
- page: GearedPagination::Recordset.new(cards, per_page:).page(1),
- filter: filter
- end
-
- def page_and_filter_for_closed_cards
- if @filter.indexed_by.stalled?
- page_and_filter_for(@filter, per_page: CardsController::PAGE_SIZE) { |cards| cards.recently_closed_first }
- else
- page_and_filter_for(@filter.with(indexed_by: "closed"), per_page: CardsController::PAGE_SIZE) { |cards| cards.recently_closed_first }
- end
- end
-end
diff --git a/app/controllers/concerns/filter_scoped.rb b/app/controllers/concerns/filter_scoped.rb
index b4e9aa526..2c422be2a 100644
--- a/app/controllers/concerns/filter_scoped.rb
+++ b/app/controllers/concerns/filter_scoped.rb
@@ -13,16 +13,18 @@ module FilterScoped
end
private
- DEFAULT_PARAMS = { indexed_by: "all", sorted_by: "latest" }
-
def set_filter
if params[:filter_id].present?
@filter = Current.user.filters.find(params[:filter_id])
else
- @filter = Current.user.filters.from_params params.reverse_merge(**DEFAULT_PARAMS).permit(*Filter::PERMITTED_PARAMS)
+ @filter = Current.user.filters.from_params filter_params
end
end
+ def filter_params
+ params.reverse_merge(**Filter.default_values).permit(*Filter::PERMITTED_PARAMS)
+ end
+
def set_user_filtering
@user_filtering = User::Filtering.new(Current.user, @filter, expanded: expanded_param)
end
diff --git a/app/controllers/filters/settings_refreshes_controller.rb b/app/controllers/filters/settings_refreshes_controller.rb
new file mode 100644
index 000000000..b7dfac0ec
--- /dev/null
+++ b/app/controllers/filters/settings_refreshes_controller.rb
@@ -0,0 +1,6 @@
+class Filters::SettingsRefreshesController < ApplicationController
+ include FilterScoped
+
+ def create
+ end
+end
diff --git a/app/controllers/filters_controller.rb b/app/controllers/filters_controller.rb
index cbb44963b..efb06aaef 100644
--- a/app/controllers/filters_controller.rb
+++ b/app/controllers/filters_controller.rb
@@ -1,28 +1,21 @@
class FiltersController < ApplicationController
- before_action :set_filter, only: :destroy
+ before_action :set_filters
def create
@filter = Current.user.filters.remember filter_params
- redirect_to cards_path(filter_id: @filter.id)
end
def destroy
- filter_params = @filter.as_params
+ @filter = Current.user.filters.find(params[:id])
@filter.destroy!
-
- if request.referer == root_url
- redirect_to root_path
- else
- redirect_to cards_path(filter_params)
- end
end
private
- def set_filter
- @filter = Current.user.filters.find params[:id]
+ def set_filters
+ @filters = Current.user.filters
end
def filter_params
- params.permit(*Filter::PERMITTED_PARAMS).compact_blank
+ Filter.normalize_params(params.permit(*Filter::PERMITTED_PARAMS))
end
end
diff --git a/app/helpers/accesses_helper.rb b/app/helpers/accesses_helper.rb
index 0fb8e8f36..833a1a2b0 100644
--- a/app/helpers/accesses_helper.rb
+++ b/app/helpers/accesses_helper.rb
@@ -17,12 +17,12 @@ module AccessesHelper
cached: ->(user) { [ user, selected ] }
end
- def access_involvement_advance_button(collection, user, show_watchers: true)
+ def access_involvement_advance_button(collection, user, show_watchers: true, icon_only: false)
access = collection.access_for(user)
turbo_frame_tag dom_id(collection, :involvement_button) do
concat collection_watchers_list(collection) if show_watchers
- concat involvement_button(collection, access, show_watchers)
+ concat involvement_button(collection, access, show_watchers, icon_only)
end
end
@@ -43,7 +43,7 @@ module AccessesHelper
end
end
- def involvement_button(collection, access, show_watchers)
+ def involvement_button(collection, access, show_watchers, icon_only)
involvement_label_id = dom_id(collection, :involvement_label)
button_to(
@@ -57,7 +57,7 @@ module AccessesHelper
icon_tag("notification-bell-#{access.involvement.dasherize}"),
tag.span(
involvement_access_label(collection, access.involvement),
- class: "txt-nowrap txt-uppercase",
+ class: class_names("txt-nowrap txt-uppercase", "for-screen-reader": icon_only),
id: involvement_label_id
)
])
diff --git a/app/helpers/filters_helper.rb b/app/helpers/filters_helper.rb
index 797682c51..7c75954ae 100644
--- a/app/helpers/filters_helper.rb
+++ b/app/helpers/filters_helper.rb
@@ -35,8 +35,8 @@ module FiltersHelper
}, &block
end
- def filter_collapsible_section(title, &block)
- tag.details class: "popup__section", open: true do
+ def collapsible_nav_section(title, **properties, &block)
+ tag.details class: "popup__section", data: { action: "toggle->nav-section-expander#toggle", nav_section_expander_target: "section", nav_section_expander_key_value: title.parameterize }, open: true, **properties do
concat(tag.summary(class: "popup__section-title") do
concat title
concat icon_tag "caret-down"
diff --git a/app/helpers/webhooks_helper.rb b/app/helpers/webhooks_helper.rb
new file mode 100644
index 000000000..7fe91d8c0
--- /dev/null
+++ b/app/helpers/webhooks_helper.rb
@@ -0,0 +1,27 @@
+module WebhooksHelper
+ ACTION_LABELS = {
+ card_assigned: "Card assigned",
+ card_closed: "Card closed",
+ card_collection_changed: "Card collection changed",
+ card_due_date_added: "Card due date added",
+ card_due_date_changed: "Card due date changed",
+ card_due_date_removed: "Card due date removed",
+ card_published: "Card published",
+ card_reopened: "Card reopened",
+ card_staged: "Card staged",
+ card_title_changed: "Card title changed",
+ card_unassigned: "Card unassigned",
+ card_unstaged: "Card unstaged",
+ comment_created: "Comment created"
+ }.with_indifferent_access.freeze
+
+ def webhook_action_options(actions = Webhook::PERMITTED_ACTIONS)
+ actions.each_with_object({}) do |action, hash|
+ hash[action.to_s] = webhook_action_label(action)
+ end
+ end
+
+ def webhook_action_label(action)
+ ACTION_LABELS[action] || action.to_s.humanize
+ end
+end
diff --git a/app/javascript/controllers/bar_controller.js b/app/javascript/controllers/bar_controller.js
index 5ceed089c..31fc11e3f 100644
--- a/app/javascript/controllers/bar_controller.js
+++ b/app/javascript/controllers/bar_controller.js
@@ -18,6 +18,7 @@ export default class extends Controller {
this.dialogOutlet.close()
this.#clearTurboFrame()
+ this.searchInputTarget.querySelector("input").value = ""
this.#showItem(this.buttonsContainerTarget)
this.#hideItem(this.searchInputTarget)
if (this.hasAskInputTarget) {
diff --git a/app/javascript/controllers/combobox_controller.js b/app/javascript/controllers/combobox_controller.js
new file mode 100644
index 000000000..02579e5a1
--- /dev/null
+++ b/app/javascript/controllers/combobox_controller.js
@@ -0,0 +1,85 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ #hiddenField
+
+ static targets = [ "label", "item", "hiddenFieldTemplate" ]
+ static values = {
+ selectPropertyName: { type: String, default: "aria-checked" },
+ defaultValue: String,
+ defaultLabel: String
+ }
+ static classes = ["withDefault"]
+
+ connect() {
+ this.#selectedItem = this.#selectedItem
+ }
+
+ change(event) {
+ const item = event.target.closest("[role='checkbox']")
+ if (item) {
+ this.#selectedItem = item
+ }
+ }
+
+ get #selectedLabel() {
+ const selectedValue = this.#selectedItemValue()
+
+ if (this.hasDefaultLabelValue && (selectedValue === this.defaultValueValue || !selectedValue)) {
+ return this.defaultLabelValue
+ }
+
+ return this.#selectedItem?.dataset?.comboboxLabel || ""
+ }
+
+ get #selectedItem() {
+ return this.itemTargets.find(item => item.getAttribute(this.selectPropertyNameValue) === "true")
+ }
+
+ #selectedItemValue() {
+ return this.#selectedItem?.dataset?.comboboxValue || ""
+ }
+
+ set #selectedItem(item) {
+ if (!item) return
+
+ this.#clearSelection()
+ item.setAttribute(this.selectPropertyNameValue, "true")
+ this.labelTarget.textContent = this.#selectedLabel
+ this.hiddenField.value = item.dataset.comboboxValue
+ this.hiddenField.disabled = !item.dataset.comboboxValue
+ this.#updateWithDefaultClass()
+ }
+
+ #clearSelection() {
+ this.itemTargets.forEach(target => {
+ target.setAttribute(this.selectPropertyNameValue, "false")
+ })
+ }
+
+ get hiddenField() {
+ if (!this.#hiddenField) {
+ this.#hiddenField = this.#buildHiddenField()
+ }
+ return this.#hiddenField
+ }
+
+ #buildHiddenField() {
+ const [field] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children
+ this.element.appendChild(field)
+ return field
+ }
+
+ #updateWithDefaultClass() {
+ if (this.hasWithDefaultClass && this.hasDefaultValueValue) {
+ const selectedValue = this.#selectedItemValue()
+ const shouldHaveClass = selectedValue === this.defaultValueValue
+
+ if (shouldHaveClass) {
+ this.element.classList.add(this.withDefaultClass)
+ } else {
+ this.element.classList.remove(this.withDefaultClass)
+ }
+ }
+ }
+}
diff --git a/app/javascript/controllers/filter_controller.js b/app/javascript/controllers/filter_controller.js
index 688c46155..7f3770a8e 100644
--- a/app/javascript/controllers/filter_controller.js
+++ b/app/javascript/controllers/filter_controller.js
@@ -11,7 +11,7 @@ export default class extends Controller {
filter() {
this.itemTargets.forEach(item => {
- if (filterMatches(item.innerText, this.inputTarget.value)) {
+ if (filterMatches(item.textContent, this.inputTarget.value)) {
item.removeAttribute("hidden")
} else {
item.toggleAttribute("hidden", true)
diff --git a/app/javascript/controllers/filter_settings_controller.js b/app/javascript/controllers/filter_settings_controller.js
new file mode 100644
index 000000000..85784583a
--- /dev/null
+++ b/app/javascript/controllers/filter_settings_controller.js
@@ -0,0 +1,68 @@
+import { Controller } from "@hotwired/stimulus"
+import { debounce } from "helpers/timing_helpers";
+import { post } from "@rails/request.js"
+
+export default class extends Controller {
+ static classes = ["filtersSet"]
+ static targets = ["field", "form"]
+ static values = { refreshUrl: String }
+
+ initialize() {
+ this.debouncedToggle = debounce(this.#toggle.bind(this), 50)
+ }
+
+ connect() {
+ this.#toggle()
+ }
+
+ change() {
+ this.#toggle()
+ this.#refreshSaveToggleButton()
+ }
+
+ async fieldTargetConnected(field) {
+ this.debouncedToggle()
+ }
+
+ #toggle() {
+ this.element.classList.toggle(this.filtersSetClass, this.#hasFiltersSet)
+ }
+
+ get #hasFiltersSet() {
+ return this.fieldTargets.some(field => this.#isFieldSet(field))
+ }
+
+ #isFieldSet(field) {
+ const value = field.value?.trim()
+
+ if (!value) return false
+
+ const defaultValue = this.#defaultValueForField(field)
+ return defaultValue ? value !== defaultValue : true
+ }
+
+ #defaultValueForField(field) {
+ const comboboxContainer = field.closest("[data-combobox-default-value-value]")
+ return comboboxContainer?.dataset?.comboboxDefaultValueValue
+ }
+
+ #refreshSaveToggleButton() {
+ post(this.refreshUrlValue, {
+ body: this.#collectFilterFormData(),
+ responseKind: "turbo-stream"
+ })
+ }
+
+ #collectFilterFormData() {
+ const formData = new FormData()
+
+ this.formTargets.forEach(form => {
+ const hiddenFields = form.querySelectorAll('input[type="hidden"]:not([disabled])[name]')
+ hiddenFields.forEach(field => {
+ formData.append(field.name, field.value)
+ })
+ })
+
+ return formData
+ }
+}
diff --git a/app/javascript/controllers/multi_selection_combobox_controller.js b/app/javascript/controllers/multi_selection_combobox_controller.js
new file mode 100644
index 000000000..70a853b2b
--- /dev/null
+++ b/app/javascript/controllers/multi_selection_combobox_controller.js
@@ -0,0 +1,98 @@
+import { Controller } from "@hotwired/stimulus"
+import { toSentence } from "helpers/text_helpers"
+
+export default class extends Controller {
+ #hiddenField
+
+ static targets = [ "label", "item", "hiddenFieldTemplate" ]
+ static values = {
+ selectPropertyName: { type: String, default: "aria-checked" },
+ defaultValue: String,
+ noSelectionLabel: { type: String, default: "No selection" },
+ labelPrefix: String
+ }
+
+ connect() {
+ this.labelTarget.textContent = this.#selectedLabel
+ this.#updateHiddenFields()
+ }
+
+ change(event) {
+ const item = event.target.closest("[role='checkbox']")
+ if (item) {
+ this.#toggleSelection(item)
+ }
+ }
+
+
+ clear(event) {
+ this.#deselectAll()
+ this.#updateHiddenFields()
+ this.labelTarget.textContent = this.#selectedLabel
+ }
+
+ get #selectedLabel() {
+ const selectedValues = this.#selectedValues()
+ if (selectedValues.length === 0) {
+ return this.noSelectionLabelValue
+ }
+
+ const labels = this.#selectedItems().map(item => item.dataset.multiSelectionComboboxLabel)
+ const sentence = toSentence(labels, {
+ two_words_connector: " or ",
+ last_word_connector: ", or "
+ })
+
+ return this.hasLabelPrefixValue ? `${this.labelPrefixValue} ${sentence}` : sentence
+ }
+
+ #toggleSelection(item) {
+ const isSelected = item.getAttribute(this.selectPropertyNameValue) === "true"
+
+ if (isSelected) {
+ item.setAttribute(this.selectPropertyNameValue, "false")
+ } else {
+ item.setAttribute(this.selectPropertyNameValue, "true")
+ }
+
+ this.#updateHiddenFields()
+ this.labelTarget.textContent = this.#selectedLabel
+ }
+
+ #updateHiddenFields() {
+ this.#clearHiddenFields()
+ this.#addHiddenFields()
+ }
+
+ #deselectAll() {
+ this.itemTargets.forEach(item => {
+ item.setAttribute(this.selectPropertyNameValue, "false")
+ })
+ }
+
+ #selectedItems() {
+ return this.itemTargets.filter(item =>
+ item.getAttribute(this.selectPropertyNameValue) === "true"
+ )
+ }
+
+ #selectedValues() {
+ return this.#selectedItems().map(item => item.dataset.multiSelectionComboboxValue)
+ }
+
+ #clearHiddenFields() {
+ this.element.querySelectorAll('input[type="hidden"]').forEach(field => {
+ if (field !== this.hiddenField) {
+ field.remove()
+ }
+ })
+ }
+
+ #addHiddenFields() {
+ this.#selectedValues().forEach(value => {
+ const [field] = this.hiddenFieldTemplateTarget.content.cloneNode(true).children
+ field.value = value
+ this.element.appendChild(field)
+ })
+ }
+}
diff --git a/app/javascript/controllers/nav_section_expander_controller.js b/app/javascript/controllers/nav_section_expander_controller.js
new file mode 100644
index 000000000..d0db6e014
--- /dev/null
+++ b/app/javascript/controllers/nav_section_expander_controller.js
@@ -0,0 +1,49 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static values = { key: String }
+ static targets = [ "input", "section" ]
+
+ sectionTargetConnected() {
+ this.#restoreToggles()
+ }
+
+ toggle(event) {
+ const section = event.target
+ if (section.hasAttribute("data-is-filtering")) return
+
+ const key = this.#localStorageKeyFor(section)
+ if (section.open) {
+ localStorage.removeItem(key)
+ } else {
+ localStorage.setItem(key, true)
+ }
+ }
+
+ showWhileFiltering() {
+ if (this.inputTarget.value) {
+ this.#expandAll();
+ } else {
+ this.#restoreToggles()
+ }
+ }
+
+ #expandAll() {
+ this.sectionTargets.forEach(section => {
+ section.setAttribute("data-is-filtering", true)
+ section.open = true
+ })
+ }
+
+ #restoreToggles() {
+ this.sectionTargets.forEach(section => {
+ const key = this.#localStorageKeyFor(section)
+ section.open = !localStorage.getItem(key)
+ section.removeAttribute("data-is-filtering")
+ })
+ }
+
+ #localStorageKeyFor(section) {
+ return section.getAttribute("data-nav-section-expander-key-value")
+ }
+}
diff --git a/app/javascript/helpers/text_helpers.js b/app/javascript/helpers/text_helpers.js
index 22593fe6f..d886e94b9 100644
--- a/app/javascript/helpers/text_helpers.js
+++ b/app/javascript/helpers/text_helpers.js
@@ -11,3 +11,27 @@ export function normalizeFilteredText(string) {
export function filterMatches(text, potentialMatch) {
return normalizeFilteredText(text).includes(normalizeFilteredText(potentialMatch))
}
+
+export function toSentence(array, options = {}) {
+ const defaultConnectors = {
+ words_connector: ", ",
+ two_words_connector: " and ",
+ last_word_connector: ", and "
+ }
+
+ const connectors = { ...defaultConnectors, ...options }
+
+ if (array.length === 0) {
+ return ""
+ }
+
+ if (array.length === 1) {
+ return array[0]
+ }
+
+ if (array.length === 2) {
+ return array.join(connectors.two_words_connector)
+ }
+
+ return array.slice(0, -1).join(connectors.words_connector) + connectors.last_word_connector + array[array.length - 1]
+}
diff --git a/app/models/card.rb b/app/models/card.rb
index 00214fa5d..02394131c 100644
--- a/app/models/card.rb
+++ b/app/models/card.rb
@@ -1,5 +1,5 @@
class Card < ApplicationRecord
- include Assignable, Attachments, Closeable, Colored, Engageable, Entropic, Eventable,
+ include Assignable, Attachments, Cacheable, Closeable, Colored, Engageable, Entropic, Eventable,
Golden, Mentions, Multistep, Pinnable, Promptable, Readable, Searchable,
Staged, Stallable, Statuses, Taggable, Watchable
@@ -41,10 +41,6 @@ class Card < ApplicationRecord
delegate :accessible_to?, to: :collection
- def cache_key
- [ super, collection.name ].compact.join("/")
- end
-
def card
self
end
diff --git a/app/models/card/cacheable.rb b/app/models/card/cacheable.rb
new file mode 100644
index 000000000..94c6b6d11
--- /dev/null
+++ b/app/models/card/cacheable.rb
@@ -0,0 +1,27 @@
+module Card::Cacheable
+ extend ActiveSupport::Concern
+
+ def cache_key
+ [ super, collection.name ].compact.join("/")
+ end
+
+ def cache_invalidation_parts
+ @cache_invalidation_parts ||= InvalidationParts.new(self)
+ end
+
+ class InvalidationParts
+ attr_reader :card
+
+ def initialize(card)
+ @card = card
+ end
+
+ def for_perma(*other)
+ [ card, Workflow.all, User.all, Tag.all, *other ]
+ end
+
+ def for_preview(*other)
+ [ card, card.workflow, Workflow.all, card.collection.entropy_configuration, card.collection.publication, *other ]
+ end
+ end
+end
diff --git a/app/models/card/staged.rb b/app/models/card/staged.rb
index fd9b1d09c..4cd497abd 100644
--- a/app/models/card/staged.rb
+++ b/app/models/card/staged.rb
@@ -7,10 +7,7 @@ module Card::Staged
before_create :assign_initial_stage
scope :in_stage, ->(stage) { where stage: stage }
- end
-
- def workflow
- stage&.workflow
+ delegate :workflow, to: :collection, allow_nil: true
end
def staged?
diff --git a/app/models/cards/columns.rb b/app/models/cards/columns.rb
new file mode 100644
index 000000000..2ef9b9957
--- /dev/null
+++ b/app/models/cards/columns.rb
@@ -0,0 +1,45 @@
+class Cards::Columns
+ attr_reader :user_filtering, :page_size
+
+ delegate :filter, to: :user_filtering
+
+ def initialize(user_filtering:, page_size:)
+ @user_filtering = user_filtering
+ @page_size = page_size
+ end
+
+ def considering
+ @considering ||= build_column_for "considering"
+ end
+
+ def on_deck
+ @on_deck ||= build_column_for "on_deck"
+ end
+
+ def doing
+ @doing ||= build_column_for "doing"
+ end
+
+ def closed
+ @closed ||= if filter.indexed_by.stalled?
+ build_column(filter) { |cards| cards.recently_closed_first }
+ else
+ build_column(filter.with(indexed_by: "closed")) { |cards| cards.recently_closed_first }
+ end
+ end
+
+ def cache_key
+ ActiveSupport::Cache.expand_cache_key([ considering, on_deck, doing, closed, Workflow.all, user_filtering ])
+ end
+
+ private
+ def build_column_for(engagement_status)
+ build_column(filter.with(engagement_status: engagement_status))
+ end
+
+ def build_column(filter, &block)
+ cards = block ? yield(filter.cards) : filter.cards
+
+ Column.new(page: GearedPagination::Recordset.new(cards, per_page: page_size).page(1), filter: filter, user_filtering: user_filtering)
+ end
+end
diff --git a/app/models/cards/columns/column.rb b/app/models/cards/columns/column.rb
new file mode 100644
index 000000000..c48c29fb8
--- /dev/null
+++ b/app/models/cards/columns/column.rb
@@ -0,0 +1,17 @@
+class Cards::Columns::Column
+ attr_reader :page, :filter, :user_filtering
+
+ def initialize(page:, filter:, user_filtering:)
+ @page = page
+ @filter = filter
+ @user_filtering = user_filtering
+ end
+
+ def cards
+ page.records
+ end
+
+ def cache_key
+ ActiveSupport::Cache.expand_cache_key([ cards ])
+ end
+end
diff --git a/app/models/collection/accessible.rb b/app/models/collection/accessible.rb
index 022185069..4e8cd40b2 100644
--- a/app/models/collection/accessible.rb
+++ b/app/models/collection/accessible.rb
@@ -43,8 +43,8 @@ module Collection::Accessible
def clean_inaccessible_data_for(user)
return if accessible_to?(user)
- clean_inaccessible_records user.notifications
- clean_inaccessible_records user.mentions
+ mentions_for_user(user).destroy_all
+ notifications_for_user(user).destroy_all
end
private
@@ -56,11 +56,33 @@ module Collection::Accessible
accesses.grant_to(User.all) if all_access_previously_changed?(to: true)
end
- def clean_inaccessible_records(records)
- records.find_each do |record|
- if record.card&.collection == self
- record.destroy
- end
- end
+ def mentions_for_user(user)
+ # Query handles 2 paths:
+ #
+ # 1. Mention->Card
+ # 2. Mention->Comment->Card
+ user.mentions
+ .joins("LEFT JOIN cards ON mentions.source_id = cards.id AND mentions.source_type = 'Card'")
+ .joins("LEFT JOIN comments ON mentions.source_id = comments.id AND mentions.source_type = 'Comment'")
+ .joins("LEFT JOIN cards AS comment_cards ON comments.card_id = comment_cards.id")
+ .where("(mentions.source_type = 'Card' AND cards.collection_id = ?) OR (mentions.source_type = 'Comment' AND comment_cards.collection_id = ?)", id, id)
+ end
+
+ def notifications_for_user(user)
+ # Query handles 2 paths:
+ #
+ # 1. Notification->Event->Card
+ # 2. Notification->Event->Comment->Card
+ #
+ # Notification->Event->Mention->Card and Notification->Event->Mention->Comment->Card are
+ # handled by destroying mentions_for_user.
+ user.notifications
+ .joins("LEFT JOIN events ON notifications.source_id = events.id AND notifications.source_type = 'Event'")
+ .joins("LEFT JOIN cards AS event_cards ON events.eventable_id = event_cards.id AND events.eventable_type = 'Card'")
+ .joins("LEFT JOIN comments AS event_comments ON events.eventable_id = event_comments.id AND events.eventable_type = 'Comment'")
+ .joins("LEFT JOIN cards AS event_comment_cards ON event_comments.card_id = event_comment_cards.id")
+ .where("(notifications.source_type = 'Event' AND events.eventable_type = 'Card' AND event_cards.collection_id = ?) OR
+ (notifications.source_type = 'Event' AND events.eventable_type = 'Comment' AND event_comment_cards.collection_id = ?)",
+ id, id)
end
end
diff --git a/app/models/filter/params.rb b/app/models/filter/params.rb
index 7685c87fc..81ba67c99 100644
--- a/app/models/filter/params.rb
+++ b/app/models/filter/params.rb
@@ -28,7 +28,13 @@ module Filter::Params
end
def normalize_params(params)
- params.to_h.compact_blank.reject(&method(:default_value?)).sort_by { |name, _| name.to_s }
+ params
+ .to_h
+ .compact_blank
+ .reject(&method(:default_value?))
+ .collect { |name, value| [ name, value.is_a?(Array) ? value.collect(&:to_s) : value.to_s ] }
+ .sort_by { |name, _| name.to_s }
+ .to_h
end
end
diff --git a/app/models/filter/resources.rb b/app/models/filter/resources.rb
index e4d84ba99..da71ce982 100644
--- a/app/models/filter/resources.rb
+++ b/app/models/filter/resources.rb
@@ -21,4 +21,16 @@ module Filter::Resources
def collections
creator.collections.where id: super.ids
end
+
+ def collection_titles
+ if collections.none?
+ [ Collection.one? ? collections.first.name : "All collections" ]
+ else
+ collections.map(&:name)
+ end
+ end
+
+ def collections_label
+ collection_titles.to_sentence
+ end
end
diff --git a/app/models/user/filtering.rb b/app/models/user/filtering.rb
index d0fddc962..9f321bbed 100644
--- a/app/models/user/filtering.rb
+++ b/app/models/user/filtering.rb
@@ -3,7 +3,7 @@ class User::Filtering
attr_reader :user, :filter, :expanded
- delegate :as_params, :any?, :single_collection, to: :filter
+ delegate :as_params, :single_collection, to: :filter
delegate :only_closed?, to: :filter
def initialize(user, filter, expanded: false)
@@ -15,15 +15,11 @@ class User::Filtering
end
def selected_collection_titles
- if filter.collections.none?
- [ collections.one? ? collections.first.name : "All collections" ]
- else
- filter.collections.map(&:name)
- end
+ filter.collection_titles
end
def selected_collections_label
- selected_collection_titles.to_sentence
+ filter.collections_label
end
def tags
diff --git a/app/models/user/highlights.rb b/app/models/user/highlights.rb
index 8383e6a44..43a6745f8 100644
--- a/app/models/user/highlights.rb
+++ b/app/models/user/highlights.rb
@@ -15,6 +15,7 @@ module User::Highlights
def generate_weekly_highlights(date = Time.current)
in_time_zone do
+ date = date - 1.day if date.sunday?
PeriodHighlights.create_or_find_for collections, starts_at: highlights_starts_at(date), duration: 1.week
end
end
@@ -27,7 +28,7 @@ module User::Highlights
private
def highlights_starts_at(date = Time.current)
- date = date - 1.day if date.sunday?
+ date = date.in_time_zone(timezone)
date.beginning_of_week(:sunday)
end
end
diff --git a/app/views/cards/_container.html.erb b/app/views/cards/_container.html.erb
index 3c38bc5a8..abd4c3c9a 100644
--- a/app/views/cards/_container.html.erb
+++ b/app/views/cards/_container.html.erb
@@ -1,4 +1,4 @@
-<% cache card do %>
+<% cache card.cache_invalidation_parts.for_perma do %>
<%= render "cards/container/status", card: card %>
diff --git a/app/views/cards/columns/_column.html.erb b/app/views/cards/columns/_column.html.erb
new file mode 100644
index 000000000..e2da152a4
--- /dev/null
+++ b/app/views/cards/columns/_column.html.erb
@@ -0,0 +1 @@
+<%= render partial: "cards/display/preview", collection: column.cards, as: :card, locals: { draggable: draggable }, cached: ->(card) { card.cache_invalidation_parts.for_preview } %>
\ No newline at end of file
diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb
index 41b430536..2f580a519 100644
--- a/app/views/cards/display/_preview.html.erb
+++ b/app/views/cards/display/_preview.html.erb
@@ -1,43 +1,41 @@
<% draggable = local_assigns.fetch(:draggable, false) && card.published? %>
-<% cache cacheable_preview_parts_for(card, draggable) do %>
- <%= card_article_tag card, class: "card", draggable: draggable, data: { id: card.id, drag_and_drop_target: "item" } do %>
-
-