Merge branch 'main' into colors

* main: (284 commits)
  Polish closed stamp
  Add new card button to considering column
  Let's leave inside the menu for now
  Improve terminal history behavior
  More space between
  Add a navigation hint, make "All collections" link navigate like the others
  Reorder methods
  Fix: dismiss notifications from comments
  Fix card icons
  Filter collections menus, add keyboard shortcuts
  Style
  Space between
  Remove debug
  Consistent sizing
  Configure deployment for load balancing
  Configure the autoclose period at the collection level
  Rename to avoid using the same name
  Add brakeman rule
  Collections can be private now too
  Not used outside the class anymore
  ...
This commit is contained in:
Jason Zimdars
2025-04-25 15:59:09 -05:00
243 changed files with 2903 additions and 1882 deletions
+2
View File
@@ -0,0 +1,2 @@
[env]
ARTENANT = "development-tenant"
+15 -15
View File
@@ -1,6 +1,6 @@
GIT
remote: https://github.com/basecamp/active_record-tenanted
revision: 8758240be33a6f6f7e7f56486a2e78008c193030
revision: f04f4f0a153ae5da65b400021aa97fdb28ac86b5
specs:
active_record-tenanted (0.1.0)
activerecord (>= 8.1.alpha)
@@ -18,7 +18,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 32d96235810e268eb5335a7837b13cda3f8b653d
revision: 0ed3cd46d0038f8108ed92edb308c81fa6cc3730
branch: main
specs:
actioncable (8.1.0.alpha)
@@ -145,7 +145,7 @@ GEM
xpath (~> 3.2)
chunky_png (1.4.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
connection_pool (2.5.2)
crass (1.0.6)
date (3.4.1)
debug (1.10.0)
@@ -208,7 +208,7 @@ GEM
mini_portile2 (2.8.8)
minitest (5.25.5)
msgpack (1.8.0)
net-imap (0.5.6)
net-imap (0.5.7)
date
net-protocol
net-pop (0.1.2)
@@ -223,24 +223,24 @@ GEM
net-protocol
net-ssh (7.3.0)
nio4r (2.7.4)
nokogiri (1.18.7)
nokogiri (1.18.8)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.7-aarch64-linux-gnu)
nokogiri (1.18.8-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-aarch64-linux-musl)
nokogiri (1.18.8-aarch64-linux-musl)
racc (~> 1.4)
nokogiri (1.18.7-arm-linux-gnu)
nokogiri (1.18.8-arm-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-arm-linux-musl)
nokogiri (1.18.8-arm-linux-musl)
racc (~> 1.4)
nokogiri (1.18.7-arm64-darwin)
nokogiri (1.18.8-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-darwin)
nokogiri (1.18.8-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-gnu)
nokogiri (1.18.8-x86_64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.7-x86_64-linux-musl)
nokogiri (1.18.8-x86_64-linux-musl)
racc (~> 1.4)
ostruct (0.6.1)
parallel (1.26.3)
@@ -264,7 +264,7 @@ GEM
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.12)
rack (3.1.13)
rack-contrib (2.5.0)
rack (< 4)
rack-session (2.1.0)
@@ -373,7 +373,7 @@ GEM
ostruct
stimulus-rails (1.3.4)
railties (>= 6.0.0)
stringio (3.1.6)
stringio (3.1.7)
thor (1.3.2)
thruster (0.1.12)
thruster (0.1.12-aarch64-linux)
+2 -2
View File
@@ -11,8 +11,8 @@
/* Text */
--text-xx-small: 0.55rem;
--text-x-small: 0.7rem;
--text-small: 0.8rem;
--text-x-small: 0.75rem;
--text-small: 0.85rem;
--text-normal: 1rem;
--text-medium: 1.1rem;
--text-large: 1.5rem;
+2 -1
View File
@@ -10,7 +10,8 @@
margin: 0;
place-items: center;
img {
img,
.icon {
aspect-ratio: 1;
block-size: auto;
border-radius: var(--avatar-border-radius);
+13
View File
@@ -85,6 +85,19 @@
list-style: none;
}
kbd {
border: 1px solid;
border-radius: .3em;
box-shadow: 0 0.1em 0 currentColor;
font-family: var(--font-mono);
font-size: var(--text-x-small);
font-weight: 600;
opacity: 0.7;
padding: 0em 0.4em;
text-transform: uppercase;
white-space: nowrap;
}
video {
max-inline-size: 100%;
}
+1
View File
@@ -92,6 +92,7 @@
border-color: var(--card-color) !important;
border-style: dashed !important;
box-shadow: none !important;
}
.card__background {
+16 -27
View File
@@ -15,7 +15,7 @@
"qw notch-top we"
"actions-left card actions-right"
"er notch-bottom rt";
grid-template-columns: 48px minmax(0, 960px) 48px;
grid-template-columns: 48px minmax(0, 1120px) 48px;
inline-size: fit-content;
max-inline-size: 100%;
margin-inline: auto;
@@ -31,6 +31,15 @@
border: none;
}
.card__meta,
.card__stages {
font-size: var(--text-small);
}
.card__stages {
max-inline-size: 32ch;
}
}
/* Children
@@ -100,7 +109,7 @@
z-index: 1;
}
.btn:not(.popup__item, .btn--plain) {
.btn:not(.popup__item, .btn--plain, .btn--reversed) {
--btn-background: var(--card-color);
--btn-color: var(--color-ink-inverted);
}
@@ -110,6 +119,11 @@
text-decoration: underline;
}
.btn--reversed {
--btn-background: var(--color-bg);
--btn-color: var(--card-color);
}
}
.card-perma__closure-message {
@@ -119,29 +133,4 @@
padding-left: 1ch;
}
}
/* Variants
/* ------------------------------------------------------------------------ */
.card-perma--pointing {
--arrow-size: 20em;
--aspect-ratio: 7;
position: relative;
margin-block-end: calc(var(--arrow-size) / var(--aspect-ratio));
&::after {
aspect-ratio: var(--aspect-ratio);
background-color: var(--color-container);
clip-path: polygon(50% 100%, 100% 0, 0 0);
inline-size: var(--arrow-size);
content: "";
display: block;
inset-block-start: 99%;
inset-inline-start: 50%;
position: absolute;
transform: translateX(-50%);
z-index: -1;
}
}
}
+62 -13
View File
@@ -105,18 +105,45 @@
padding-block: var(--card-padding-block);
}
.card__description {
house-md-toolbar,
.house-md-content {
color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
}
.house-md-content {
min-block-size: 3lh;
padding: var(--block-space-half) var(--inline-space) 0 0;
}
& ~ .btn {
--btn-background: var(--card-color);
--btn-color: var(--color-ink-reversed);
}
}
.card__content {
flex: 2 1 auto;
}
.card__title {
--hover-size: 0;
--input-border-radius: 0;
--input-color: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
--lines: 3;
flex: 2 1 75%;
color: inherit;
font-size: var(--text-xx-large);
font-weight: 900;
line-height: 1.1;
line-height: 1.2;
min-block-size: 3lh;
text-wrap: balance;
.card__title-field {
.card-perma__bg & {
min-block-size: unset;
}
.card-field__title {
&:is(textarea)::placeholder {
color: inherit;
opacity: 0.5;
@@ -124,23 +151,34 @@
}
.card__title-link {
--lines: 3;
color: inherit;
}
}
.card__stages {
display: flex;
flex: 1 1 25%;
flex: 0 1 auto;
flex-direction: column;
max-width: 100%;
min-width: 0;
padding-block: var(--block-space);
justify-self: end;
max-inline-size: 20ch;
padding-block: var(--block-space-half);
}
.card--new-card-button:is(.btn) {
--btn-padding: var(--block-space) var(--inline-space-double);
--btn-border-size: 0;
--btn-color: var(--color-link);
--btn-icon-size: 1em;
--card-padding-block: calc(var(--block-space) * 1.25);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-link) 20%, var(--color-bg));
color: var(--btn-color);
font-weight: 700;
}
.card__move-button {
--btn-background: color-mix(in srgb, var(--card-color) 40%, var(--color-ink));
--btn-background: color-mix(in srgb, inherit 40%, var(--color-ink));
margin-block-start: calc(var(--block-space) * 1.2);
margin-inline-end: calc(var(--inline-space) * -4);
@@ -192,16 +230,25 @@
.card__closed {
align-items: center;
aspect-ratio: 7/4;
aspect-ratio: 8/5;
background-color: var(--color-bg);
border: var(--block-space-half) solid var(--card-color);
border-radius: 0.2em;
color: var(--card-color);
display: flex;
flex-direction: column;
inset: auto var(--block-space) var(--block-space-double) auto;
justify-content: space-between;
max-inline-size: 25ch;
min-inline-size: 18ch;
mix-blend-mode: multiply;
padding: var(--block-space-half) calc(var(--inline-space) * 1.5);
position: absolute;
transform: rotate(-10deg);
z-index: -1;
@media (prefers-color-scheme: dark) {
mix-blend-mode: screen;
}
}
.card__closed-by {
@@ -210,8 +257,10 @@
.card__closed-title {
color: var(--color-ink-inverted);
font-size: var(--text-rel-large);
font-size: 1.5em;
font-weight: 900;
line-height: 1.2;
text-align: center;
text-shadow:
-2px -2px 0 var(--card-color),
2px -2px 0 var(--card-color),
+2 -1
View File
@@ -12,7 +12,7 @@
text-align: center;
@media (min-width: 160ch) {
padding-inline: calc(var(--tray-size) + var(--inline-space) * 1.5);
padding-inline: var(--tray-size);
}
}
@@ -68,6 +68,7 @@
padding:
var(--comment-padding-block)
var(--comment-padding-inline);
word-wrap: break-word;
}
.comment__edit {
+8 -12
View File
@@ -11,9 +11,8 @@
--column-gap: clamp(var(--inline-space), 3cqmin, calc(var(--inline-space) * 3));
--grid-lines: 0.1rem;
display: grid;
grid-template-columns: 1fr 5fr 1fr;
margin: auto;
max-inline-size: min(105ch, 100%);
}
.events__day-header {
@@ -61,16 +60,7 @@
}
.events__popup {
.panel:is(&) {
max-block-size: calc(100dvh - (2 * var(--block-space-double)));
max-inline-size: calc(100dvw - (2 * var(--block-space-double)));
}
#header:has(&) {
max-inline-size: 100%;
position: relative;
z-index: 5;
}
margin-block-start: var(--block-space-double);
}
.event {
@@ -93,6 +83,12 @@
z-index: 2;
}
.event__collection {
font-size: var(--text-x-small);
font-weight: 500;
opacity: 0.66;
}
.event__grid-item {
background-color: var(--color-container);
block-size: 100%;
+8
View File
@@ -107,3 +107,11 @@
}
}
}
.filter--active li {
display: none;
&.selected {
display: flex;
}
}
+4 -3
View File
@@ -1,6 +1,8 @@
@layer components {
/* Editor */
house-md {
--placeholder: attr(placeholder);
display: flex;
flex-direction: column;
flex-grow: 1;
@@ -58,7 +60,6 @@
/* Markdown Content */
.house-md-content {
caret-color: var(--color-link);
flex-grow: 1;
font-family: var(--font-sans);
text-align: left;
@@ -66,8 +67,8 @@
padding: 1rem;
&.house-md-content-empty::before {
content: attr(placeholder);
color: var(--color-ink);
content: var(--placeholder);
color: currentColor;
opacity: 0.5;
pointer-events: none;
display: block;
+5
View File
@@ -94,6 +94,11 @@
@media (prefers-color-scheme: dark) {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
}
option {
background-color: var(--color-bg);
color: var(--color-ink);
}
}
.input--textarea {
@@ -1,5 +1,5 @@
@layer components {
.comments {
.markdown-content {
:where(h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6) {
font-weight: 800;
hyphens: auto;
+14
View File
@@ -94,3 +94,17 @@
}
}
}
.notifications__empty-message {
.notificiations-list:has(.notification) & {
display: none;
}
}
.notificiations-list--read {
display: none;
&:has(.notification) {
display: flex;
}
}
+16
View File
@@ -8,4 +8,20 @@
max-inline-size: 100%;
padding: var(--panel-padding, calc(var(--block-space) * 2));
}
.panels--two-up {
--panel-size: auto;
display: flex;
flex-wrap: wrap;
gap: var(--block-space-double);
align-content: start;
max-inline-size: min(100ch, 100%);
margin: auto;
.panel {
flex: 1 1 33%;
min-inline-size: 36ch;
}
}
}
+7 -4
View File
@@ -31,6 +31,7 @@
--text-xx-large: 2em;
border: 1px solid var(--color-canvas);
view-transition-name: unset !important;
}
.card__collection {
@@ -42,16 +43,18 @@
padding-block-end: 0;
}
.card__link {
z-index: 1;
}
.card__title {
--lines: 1;
font-size: var(--text-small);
font-weight: bold;
min-block-size: 0;
}
.card__title-link {
--lines: 1;
}
.card__stages {
padding: 0;
}
+87 -14
View File
@@ -1,19 +1,42 @@
@layer components {
.popup {
--panel-border-radius: 0.5em;
--panel-padding: 0.4em 0.5em 0.5em;
--panel-padding: var(--block-space);
--panel-size: auto;
inline-size: auto;
inset: 0 auto auto 0;
min-inline-size: 20ch;
max-inline-size: 40ch;
inset: 0 auto auto 50%;
min-inline-size: min(25ch, 90vw);
max-inline-size: min(50ch, 90vw);
position: absolute;
transform: translateX(-50%);
z-index: 2;
&[open] {
display: flex;
}
#header:has(&) {
max-inline-size: 100%;
position: relative;
z-index: 5;
}
}
.popup__group {
background: var(--color-subtle-light);
max-inline-size: 100%;
padding-inline: var(--inline-space);
.btn {
--btn-background: transparent;
}
@media (any-hover: hover) {
&:hover {
background: var(--color-selected);
}
}
}
.popup__item {
@@ -25,17 +48,10 @@
--hover-size: 0;
--outline-color: transparent;
flex: 1 1 auto;
justify-content: start;
&::after {
border-radius: var(--btn-border-radius);
border: var(--btn-border-size) solid var(--btn-border-color, var(--color-ink-lighter));
content: "";
display: block;
inline-size: calc(100% - var(--btn-padding) * 2);
block-size: calc(100% - var(--btn-padding) * 2);
}
max-inline-size: 100%;
text-align: start;
@media (any-hover: hover) {
&:where(:not(:active):hover) {
@@ -75,10 +91,67 @@
}
.popup__list {
max-block-size: 60dvh;
overflow: auto;
row-gap: 2px;
}
.popup__new {
--btn-icon-size: 1em;
.icon {
margin-inline-end: 0.5em;
}
padding-inline-start: var(--inline-space-half);
}
.popup__title {
font-weight: 800;
}
}
.popup--animated {
--backdrop-speed: 150ms;
--panel-size: max-content;
--speed: 150ms;
opacity: 0;
transform: scale(0.2) translateX(-50%);
transform-origin: top left;
transition:
display var(--speed) allow-discrete,
opacity var(--speed),
overlay var(--speed) allow-discrete,
transform var(--speed);
&::backdrop {
background-color: var(--color-always-black);
opacity: 0;
transform: scale(1);
transition:
display var(--backdrop-speed) allow-discrete,
opacity var(--backdrop-speed),
overlay var(--backdrop-speed) allow-discrete;
}
&[open] {
opacity: 1;
transform: scale(1) translateX(-50%);
&::backdrop {
opacity: 0.5;
}
}
@starting-style {
&[open] {
opacity: 0;
transform: scale(0.2) translateX(-50%);
}
&[open]::backdrop {
opacity: 0;
}
}
}
+3 -2
View File
@@ -22,8 +22,9 @@
}
.tag-picker__button {
font-size: 0.7em;
margin-inline-start: 0.5em;
font-size: 0.6em;
margin-block-start: 0.1em;
margin-inline-start: 0.2em;
+ .panel {
--panel-size: 18ch;
+4
View File
@@ -0,0 +1,4 @@
.users-list {
max-block-size: 40dvh;
overflow: auto;
}
+2
View File
@@ -23,6 +23,7 @@
.txt-nowrap { white-space: nowrap; }
.txt-break { word-break: break-word; }
.txt-uppercase { text-transform: uppercase; }
.txt-capitalize { text-transform: capitalize; }
.txt-link { color: var(--color-link); text-decoration: underline; }
/* Flexbox and Grid */
@@ -107,6 +108,7 @@
.pad-inline-double { padding-inline: var(--inline-space-double); }
.unpad { padding: 0; }
.unpad-block-end { padding-block-end: 0; }
.unpad-inline { padding-inline: 0; }
/* Margins */
+34 -5
View File
@@ -27,13 +27,42 @@
background-color: color(from var(--card-color) srgb r g b / 0.65);
}
}
}
.ticket & {
--btn-background: var(--color-ink);
.workflow-preview {
--border-size: 3px;
background: var(--card-color);
border-radius: 0.2em;
color: var(--color-ink-inverted);
aspect-ratio: 1;
flex: 1 1 30%;
max-inline-size: 33%;
padding: 0.5em 1em;
button {
--hover-size: 0;
appearance: none;
background: transparent;
border: none;
cursor: pointer;
inline-size: auto;
margin: 0;
max-inline-size: 100%;
outline: 0;
padding: 0;
text-align: left;
}
}
.workflow-preview--selected {
--border-color: var(--color-link);
}
.workflow-preview__swatch {
--btn-size: 1em;
--btn-border-radius: 50%;
--btn-border-size: 0;
--hover-size: 0;
cursor: auto;
}
}
@@ -6,5 +6,6 @@ class Cards::AssignmentsController < ApplicationController
def create
@card.toggle_assignment @collection.users.active.find(params[:assignee_id])
render_card_replacement
end
end
+2 -2
View File
@@ -3,11 +3,11 @@ class Cards::ClosuresController < ApplicationController
def create
@card.close(user: Current.user, reason: params[:reason])
redirect_to @card
render_card_replacement
end
def destroy
@card.reopen
redirect_to @card
render_card_replacement
end
end
@@ -3,11 +3,11 @@ class Cards::EngagementsController < ApplicationController
def create
@card.engage
redirect_to @card
render_card_replacement
end
def destroy
@card.reconsider
redirect_to @card
render_card_replacement
end
end
@@ -3,11 +3,11 @@ class Cards::GoldnessesController < ApplicationController
def create
@card.gild
redirect_to @card
render_card_replacement
end
def destroy
@card.ungild
redirect_to @card
render_card_replacement
end
end
+1 -1
View File
@@ -3,6 +3,6 @@ class Cards::ImagesController < ApplicationController
def destroy
@card.image.purge_later
redirect_to @card
render_card_replacement
end
end
+14 -10
View File
@@ -5,25 +5,29 @@ class Cards::PinsController < ApplicationController
end
def create
pin = @card.pin_by Current.user
@pin = @card.pin_by Current.user
broadcast_my_new pin
redirect_to card_pin_path(@card)
broadcast_add_pin_to_tray
render_pin_button_replacement
end
def destroy
pin = @card.unpin_by Current.user
@pin = @card.unpin_by Current.user
broadcast_my_removed pin
redirect_to card_pin_path(@card)
broadcast_remove_pin_from_tray
render_pin_button_replacement
end
private
def broadcast_my_new(pin)
pin.card.broadcast_prepend_later_to [ Current.user, :pins ], target: "pins", partial: "cards/display/preview"
def broadcast_add_pin_to_tray
@pin.broadcast_prepend_to [ Current.user, :pins_tray ], target: "pins", partial: "my/pins/pin"
end
def broadcast_my_removed(pin)
pin.broadcast_remove_to [ Current.user, :pins ]
def broadcast_remove_pin_from_tray
@pin.broadcast_remove_to [ Current.user, :pins_tray ]
end
def render_pin_button_replacement
render turbo_stream: turbo_stream.replace([ @card, :pin_button ], partial: "cards/pins/pin_button", locals: { card: @card })
end
end
+1 -2
View File
@@ -2,7 +2,6 @@ class Cards::ReadingsController < ApplicationController
include CardScoped
def create
@notification = Current.user.notifications.find_by(card: @card)
@notification.read
@notifications = @card.read_by(Current.user)
end
end
+3 -13
View File
@@ -1,18 +1,8 @@
class Cards::StagingsController < ApplicationController
include CardScoped
before_action :set_stage
def create
if @stage
@card.toggle_stage @stage
else
@card.update!(stage: nil)
end
def update
@card.change_stage_to @collection.workflow.stages.find(params[:stage_id])
render_card_replacement
end
private
def set_stage
@stage = Workflow::Stage.find_by(id: params[:stage_id])
end
end
@@ -7,6 +7,7 @@ class Cards::TaggingsController < ApplicationController
def create
@card.toggle_tag_with sanitized_tag_title_param
render_card_replacement
end
private
+15 -6
View File
@@ -25,16 +25,21 @@ class CardsController < ApplicationController
def edit
end
def update
@card.update! card_params
if @card.published?
render_card_replacement
else
redirect_to @card
end
end
def destroy
@card.destroy!
redirect_to cards_path(collection_ids: [ @card.collection ]), notice: ("Card deleted" unless @card.creating?)
end
def update
@card.update! card_params
redirect_to @card
end
private
def set_card
@card = @collection.cards.find params[:id]
@@ -49,6 +54,10 @@ class CardsController < ApplicationController
end
def card_params
params.expect(card: [ :status, :title, :color, :due_on, :image, :draft_comment, tag_ids: [] ])
params.expect(card: [ :status, :title, :description, :image, tag_ids: [] ])
end
def render_card_replacement
render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", locals: { card: @card.reload })
end
end
@@ -10,6 +10,6 @@ class Collections::WorkflowsController < ApplicationController
private
def set_workflow
@workflow = Workflow.find(params.expect(collection: [ :workflow_id ]).require(:workflow_id))
@workflow = Workflow.find(params[:collection][:workflow_id])
end
end
+3 -3
View File
@@ -6,7 +6,7 @@ class CollectionsController < ApplicationController
end
def create
@collection = Collection.create! collection_params
@collection = Collection.create! collection_params.with_defaults(all_access: true)
redirect_to cards_path(collection_ids: [ @collection ])
end
@@ -19,7 +19,7 @@ class CollectionsController < ApplicationController
@collection.update! collection_params
@collection.accesses.revise granted: grantees, revoked: revokees
redirect_to cards_path(collection_ids: [ @collection ])
redirect_to edit_collection_path(@collection), notice: "Collection updated"
end
def destroy
@@ -33,7 +33,7 @@ class CollectionsController < ApplicationController
end
def collection_params
params.expect(collection: [ :name, :all_access ]).with_defaults(all_access: true)
params.expect(collection: [ :name, :all_access, :auto_close_period ])
end
def grantees
+4
View File
@@ -13,4 +13,8 @@ module CardScoped
def set_collection
@collection = @card.collection
end
def render_card_replacement
render turbo_stream: turbo_stream.replace([ @card, :card_container ], partial: "cards/container", method: :morph, locals: { card: @card.reload })
end
end
@@ -1,24 +0,0 @@
module CollectionFilterable
extend ActiveSupport::Concern
included do
before_action :set_collection_filter
end
private
def set_collection_filter
params[:collection_ids] ||= cookies[:collection_filter]&.split(",") unless params[:clear_filter]
end
def collection_filter
params[:collection_ids].presence || Current.user.collection_ids
end
def update_collection_filter
if params[:clear_filter]
cookies.delete(:collection_filter)
elsif params[:collection_ids].present?
cookies[:collection_filter] = params[:collection_ids].join(",")
end
end
end
@@ -0,0 +1,48 @@
module DayTimelinesScoped
extend ActiveSupport::Concern
included do
include FilterScoped
before_action :restore_collections_filter_from_cookie
before_action :set_day_timeline
after_action :save_collections_filter_to_cookie
end
private
def restore_collections_filter_from_cookie
if params[:clear_filter]
delete_collections_filter_cookie
else
set_collections_filter_from_cookie
end
end
def delete_collections_filter_cookie
cookies.delete(:collection_filter)
end
def set_collections_filter_from_cookie
if cookies[:collection_filter].present? && @filter.collections.blank?
@filter.collection_ids = cookies[:collection_filter].split(",")
end
end
def save_collections_filter_to_cookie
cookies[:collection_filter] = @filter.collection_ids.join(",")
end
def set_day_timeline
@day_timeline = Current.user.timeline_for(day, filter: @filter)
end
def day
if params[:day].present?
Time.zone.parse(params[:day])
else
Time.current
end
rescue ArgumentError
head :not_found
end
end
@@ -1,40 +0,0 @@
module EventsTimeline
extend ActiveSupport::Concern
include CollectionFilterable
included do
before_action :set_activity_day
end
private
def set_activity_day
@activity_day = if params[:day].present?
Time.zone.parse(params[:day])
else
Time.zone.now
end
rescue ArgumentError
raise ActionController::RoutingError
end
def events_for_activity_day
user_events.where(created_at: @activity_day.all_day)
.group_by { |event| [ event.created_at.hour, helpers.event_column(event) ] }
.map { |hour_col, events| [ hour_col, events.uniq { |e| e.id } ] }
end
def latest_event_before_activity_day
user_events.where(created_at: ...@activity_day.beginning_of_day).chronologically.last
end
def user_events
Event.where(card: user_cards)
end
def user_cards
Current.user.accessible_cards
.published_or_drafted_by(Current.user)
.where(collection_id: collection_filter)
end
end
+1 -3
View File
@@ -1,8 +1,6 @@
class Events::DaysController < ApplicationController
include EventsTimeline
include DayTimelinesScoped
def index
@events = events_for_activity_day
@next_day = latest_event_before_activity_day&.created_at
end
end
+1 -8
View File
@@ -1,13 +1,6 @@
class EventsController < ApplicationController
include EventsTimeline
include DayTimelinesScoped
def index
update_collection_filter
@collections = Current.user.collections.alphabetically
@filters = Current.user.filters.all
@events = events_for_activity_day
@next_day = latest_event_before_activity_day&.created_at
end
end
@@ -1,5 +1,5 @@
class Notifications::TraysController < ApplicationController
def show
@notifications = Current.user.notifications.unread.ordered.limit(20)
@notifications = Current.user.notifications.unread.ordered.limit(100)
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
class TerminalsController < ApplicationController
def show
@events = Event.where(card: Current.user.accessible_cards, creator: Current.user).chronologically.reverse_order.limit(20)
@events = Current.user.accessible_events.chronologically.reverse_order.limit(20)
end
def edit
-2
View File
@@ -11,8 +11,6 @@ class WorkflowsController < ApplicationController
def create
@workflow = Workflow.create! workflow_params
# FIXME: this should definitely change.
[ "Triage", "In progress", "On Hold", "Review" ].each { |name| @workflow.stages.create! name: name }
redirect_to workflows_path
end
+1 -1
View File
@@ -1,6 +1,6 @@
module AccessesHelper
def access_menu_tag(collection, &)
tag.menu class: [ "flex flex-column gap margin-none pad txt-medium", { "toggler--toggled": collection.all_access? } ], data: {
tag.menu class: [ "flex flex-column gap-half unpad margin-none txt-medium", { "toggler--toggled": collection.all_access? } ], data: {
controller: "filter toggle-class",
filter_active_class: "filter--active", filter_selected_class: "selected",
toggle_class_toggle_class: "toggler--toggled" }, &
+11 -2
View File
@@ -15,11 +15,12 @@ module CardsHelper
**options
end
def card_article_tag(card, id: dom_id(card, :ticket), **options, &block)
def card_article_tag(card, id: dom_id(card, :article), **options, &block)
classes = [
options.delete(:class),
("card--golden" if card.golden?),
("card--doing" if card.doing?)
("card--doing" if card.doing?),
("card--drafted" if card.drafted?)
].compact.join(" ")
tag.article \
@@ -29,4 +30,12 @@ module CardsHelper
**options,
&block
end
def button_to_delete_card(card)
button_to collection_card_path(card.collection, card),
method: :delete, class: "btn txt-negative borderless txt-small", data: { turbo_frame: "_top", turbo_confirm: "Are you sure you want to permanently delete this card?" } do
concat(icon_tag("trash"))
concat(tag.span("Delete this card"))
end
end
end
+12
View File
@@ -0,0 +1,12 @@
module CollectionsHelper
def collection_auto_close_options
[
[ "30 days", 30.days ],
[ "60 days", 60.days ],
[ "90 days", 90.days ],
[ "6 months", 180.days ],
[ "1 year", 365.days ],
[ "Never", nil ]
]
end
end
+51 -53
View File
@@ -1,20 +1,9 @@
module EventsHelper
def event_day_title(day)
case
when day.today?
"Today"
when day.yesterday?
"Yesterday"
else
day.strftime("%A, %B %e")
end
end
def event_column(event)
case event.action
when "closed"
when "card_closed"
3
when "published"
when "card_published"
1
else
2
@@ -33,7 +22,7 @@ module EventsHelper
end
end
def render_event_grid_cells(day, columns: 4, rows: 24)
def render_event_grid_cells(columns: 4, rows: 24)
safe_join((2..rows + 1).map do |row|
(1..columns).map do |col|
tag.div class: class_names("event__grid-item"), style: "grid-area: #{row}/#{col};"
@@ -41,19 +30,11 @@ module EventsHelper
end.flatten)
end
def render_column_headers(day = Date.current)
start_time = day.beginning_of_day
end_time = day.end_of_day
accessible_events = Event.joins(card: :collection)
.merge(Current.user.collections)
.where(created_at: start_time..end_time)
.where(cards: { collection_id: params[:collection_ids].presence || Current.user.collection_ids })
def render_column_headers(day_timeline)
headers = {
"Added" => accessible_events.where(action: "published").count,
"Added" => day_timeline.events.where(action: "card_published").count,
"Updated" => nil,
"Closed" => accessible_events.where(action: "closed").count
"Closed" => day_timeline.events.where(action: "card_closed").count
}
headers.map do |header, count|
@@ -63,47 +44,64 @@ module EventsHelper
end
def event_action_sentence(event)
if event.action.comment_created?
comment_event_action_sentence(event)
else
card_event_action_sentence(event)
end
end
def comment_event_action_sentence(event)
"#{ event_creator_name(event) } commented on <span style='color: var(--card-color)'>#{ event.eventable.card.title }</span>".html_safe
end
def event_creator_name(event)
event.creator == Current.user ? "You" : event.creator.name
end
def card_event_action_sentence(event)
card = event.eventable
title = card.title
case event.action
when "assigned"
when "card_assigned"
if event.assignees.include?(Current.user)
"#{ event.creator == Current.user ? "You" : event.creator.name } will handle <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
"#{ event_creator_name(event) } will handle <span style='color: var(--card-color)'>#{ title }</span>".html_safe
else
"#{ event.creator == Current.user ? "You" : event.creator.name } assigned #{ event.assignees.pluck(:name).to_sentence } to <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
"#{ event_creator_name(event) } assigned #{ event.assignees.pluck(:name).to_sentence } to <span style='color: var(--card-color)'>#{ title }</span>".html_safe
end
when "unassigned"
"#{ event.creator == Current.user ? "You" : event.creator.name } unassigned #{ event.assignees.include?(Current.user) ? "yourself" : event.assignees.pluck(:name).to_sentence } from <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "commented"
"#{ event.creator == Current.user ? "You" : event.creator.name } commented on <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "published"
"#{ event.creator == Current.user ? "You" : event.creator.name } added <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "closed"
"#{ event.creator == Current.user ? "You" : event.creator.name } closed <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "staged"
"#{event.creator == Current.user ? "You" : event.creator.name} moved <span style='color: var(--card-color)'>#{ event.card.title }</span> to the #{event.stage_name} stage".html_safe
when "unstaged"
"#{event.creator == Current.user ? "You" : event.creator.name} moved <span style='color: var(--card-color)'>#{ event.card.title }</span> out ofthe #{event.stage_name} stage".html_safe
when "due_date_added"
"#{event.creator == Current.user ? "You" : event.creator.name} set the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "due_date_changed"
"#{event.creator == Current.user ? "You" : event.creator.name} changed the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ event.card.title }</span>".html_safe
when "due_date_removed"
"#{event.creator == Current.user ? "You" : event.creator.name} removed the date on <span style='color: var(--card-color)'>#{ event.card.title }</span>"
when "title_changed"
"#{event.creator == Current.user ? "You" : event.creator.name} renamed <span style='color: var(--card-color)'>#{ event.card.title }</span> (was: '#{event.particulars.dig('particulars', 'old_title')})'".html_safe
when "card_unassigned"
"#{ event_creator_name(event) } unassigned #{ event.assignees.include?(Current.user) ? "yourself" : event.assignees.pluck(:name).to_sentence } from <span style='color: var(--card-color)'>#{ title }</span>".html_safe
when "card_published"
"#{ event_creator_name(event) } added <span style='color: var(--card-color)'>#{ title }</span>".html_safe
when "card_closed"
"#{ event_creator_name(event) } closed <span style='color: var(--card-color)'>#{ title }</span>".html_safe
when "card_staged"
"#{event_creator_name(event)} moved <span style='color: var(--card-color)'>#{ title }</span> to the #{event.stage_name} stage".html_safe
when "card_unstaged"
"#{event_creator_name(event)} moved <span style='color: var(--card-color)'>#{ title }</span> out ofthe #{event.stage_name} stage".html_safe
when "card_due_date_added"
"#{event_creator_name(event)} set the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ title }</span>".html_safe
when "card_due_date_changed"
"#{event_creator_name(event)} changed the date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')} on <span style='color: var(--card-color)'>#{ title }</span>".html_safe
when "card_due_date_removed"
"#{event_creator_name(event)} removed the date on <span style='color: var(--card-color)'>#{ title }</span>"
when "card_title_changed"
"#{event_creator_name(event)} renamed <span style='color: var(--card-color)'>#{ title }</span> (was: '#{event.particulars.dig('particulars', 'old_title')})'".html_safe
end
end
def event_action_icon(event)
case event.action
when "assigned"
when "card_assigned"
"assigned"
when "staged"
when "card_staged"
"bolt"
when "unstaged"
when "card_unstaged"
"bolt"
when "commented"
when "comment_created"
"comment"
when "title_changed"
when "card_title_changed"
"rename"
else
"person"
+12 -2
View File
@@ -21,9 +21,19 @@ module FiltersHelper
hidden_field_tag name, value, id: nil
end
def filter_selected_collections_label(filter)
selected_collections = if filter.collections.any?
filter.collections.collect { "<strong>#{it.name}</strong>" }.uniq.sort.to_sentence
else
"all collections"
end
"Activity in #{selected_collections}".html_safe
end
def any_filters?(filter)
filter.tags.any? || filter.assignees.any? || filter.creators.any? ||
filter.stages.any? || filter.terms.any? ||
filter.assignment_status.unassigned? || !filter.default_indexed_by?
filter.stages.any? || filter.terms.any? ||
filter.assignment_status.unassigned? || !filter.default_indexed_by?
end
end
+24 -24
View File
@@ -1,20 +1,19 @@
module NotificationsHelper
def notification_title(notification)
if notification.resource.is_a? Comment
"RE: " + notification.card.title
elsif notification_event_action(notification) == "assigned"
"Assigned to you: " + notification.card.title
else
notification.card.title
def event_notification_title(event)
case event_notification_action(event)
when "comment_created" then "RE: " + event.eventable.card.title
when "card_assigned" then "Assigned to you: " + event.eventable.title
else event.eventable.title
end
end
def notification_body(notification)
name = notification.creator.name
def event_notification_body(event)
name = event.creator.name
case notification_event_action(notification)
when "closed" then "Closed by #{name}"
when "published" then "Added by #{name}"
case event_notification_action(event)
when "card_closed" then "Closed by #{name}"
when "card_published" then "Added by #{name}"
when "comment_created" then comment_notification_body(event)
else name
end
end
@@ -22,7 +21,7 @@ module NotificationsHelper
def notification_tag(notification, &)
tag.div id: dom_id(notification), class: "notification tray__item border-radius txt-normal" do
concat(
link_to(notification.resource,
link_to(notification,
class: "notification__content border-radius shadow fill-white flex align-start txt-align-start gap flex-item-grow max-width border txt-ink",
data: { action: "click->dialog#close", turbo_frame: "_top" },
&)
@@ -33,11 +32,11 @@ module NotificationsHelper
def notification_mark_read_button(notification)
button_to read_notification_path(notification),
class: "notification__unread_indicator btn borderless",
title: "Mark as read",
data: { turbo_frame: "_top" } do
concat(image_tag("remove-med.svg", class: "unread_icon", size: 12, aria: { hidden: true }))
concat(tag.span("Mark as read", class: "for-screen-reader"))
class: "notification__unread_indicator btn borderless",
title: "Mark as read",
data: { turbo_frame: "_top" } do
concat(image_tag("remove-med.svg", class: "unread_icon", size: 12, aria: { hidden: true }))
concat(tag.span("Mark as read", class: "for-screen-reader"))
end
end
@@ -48,15 +47,16 @@ module NotificationsHelper
end
private
def notification_event_action(notification)
if notification_is_for_initial_assignement?(notification)
"assigned"
def event_notification_action(event)
if event.action.card_published? && event.eventable.assigned_to?(event.creator)
"card_assigned"
else
notification.event.action
event.action
end
end
def notification_is_for_initial_assignement?(notification)
notification.event.action == "published" && notification.card.assigned_to?(notification.user)
def comment_notification_body(event)
comment = event.eventable
"#{strip_tags(comment.body_html).blank? ? "#{event.creator.name} replied" : "#{event.creator.name}:" } #{strip_tags(comment.body_html).truncate(200)}"
end
end
+8 -9
View File
@@ -1,16 +1,15 @@
module WorkflowsHelper
def button_to_set_stage(card, stage)
button_to card_stagings_path(card, stage_id: stage),
method: :post, class: [ "btn justify-start workflow-stage txt-uppercase workflow-stage", { "workflow-stage--current": stage == card.stage } ],
form_class: "flex align-center gap-half",
data: { turbo_frame: "_top" } do
tag.span class: "overflow-ellipsis" do
stage.name
end
end
button_to \
tag.span(stage.name, class: "overflow-ellipsis"),
card_staging_path(card, stage_id: stage),
method: :put,
class: [ "btn justify-start workflow-stage txt-uppercase workflow-stage", { "workflow-stage--current": stage == card.stage } ],
form_class: "flex align-center gap-half",
data: { turbo_frame: "_top" }
end
def stage_color(stage)
stage.color.presence || Colorable::DEFAULT_COLOR
stage.color.presence || Card::DEFAULT_COLOR
end
end
@@ -1,24 +0,0 @@
import { Controller } from "@hotwired/stimulus"
import { nextFrame } from "helpers/timing_helpers"
export default class extends Controller {
static classes = [ "play" ]
static values = { playOnLoad: { type: Boolean, default: false } }
connect() {
if (this.playOnLoadValue) {
this.play()
}
}
async play() {
await nextFrame()
this.element.classList.remove(this.playClass)
this.#forceReflow()
this.element.classList.add(this.playClass)
}
#forceReflow() {
this.element.offsetWidth
}
}
@@ -1,16 +0,0 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = [ "element" ]
connect() {
this.observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) this.elementTarget.focus()
})
this.observer.observe(this.elementTarget)
}
disconnect() {
this.observer.disconnect()
}
}
@@ -1,43 +0,0 @@
import { Controller } from "@hotwired/stimulus"
const SIZES = [ "one", "two", "three", "four", "five" ]
export default class extends Controller {
static targets = [ "card" ]
connect() {
this.resize()
}
resize() {
const [ min, max ] = this.#getScoreRange()
this.cardTargets.forEach(card => {
const score = this.#currentCardScore(card)
const idx = Math.round((score - min) / (max - min) * (SIZES.length - 1))
card.style.setProperty("--card-size", `var(--card-size-${SIZES[idx]})`)
})
}
#getScoreRange() {
var min = 0, max = 1;
this.cardTargets.forEach(card => {
const score = this.#currentCardScore(card)
min = Math.min(min, score)
max = Math.max(max, score)
})
return [ min, max ]
}
#currentCardScore(el) {
const score = el.dataset.activityScore
const scoreAt = el.dataset.activityScoreAt
const daysAgo = (Date.now() / 1000 - scoreAt) / (60 * 60 * 24)
return score / (2**daysAgo)
}
}
@@ -47,7 +47,7 @@ export default class extends Controller {
#triggerChangeEvent(newValue) {
if (this.inputTarget.tagName === "HOUSE-MD") {
this.inputTarget.dispatchEvent(new CustomEvent('house-md:change', {
cards: true,
bubbles: true,
detail: {
previousContent: '',
newContent: newValue
@@ -2,7 +2,9 @@ import { Controller } from "@hotwired/stimulus"
import { differenceInDays } from "helpers/date_helpers"
export default class extends Controller {
static targets = [ "time", "date", "datetime", "shortdate", "ago", "indays", "daysago" ]
static targets = [ "time", "date", "datetime", "shortdate", "ago", "indays", "daysago", "agoorweekday" ]
static values = { refreshInterval: Number }
static classes = [ "local-time-value"]
#timer
@@ -13,7 +15,10 @@ export default class extends Controller {
this.dateTimeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short", dateStyle: "short" })
this.agoFormatter = new AgoFormatter()
this.daysagoFormatter = new DaysAgoFormatter()
this.datewithweekdayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" })
this.datewithweekdayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" })
this.indaysFormatter = new InDaysFormatter()
this.agoorweekdayFormatter = new DaysAgoOrWeekdayFormatter()
}
connect() {
@@ -60,6 +65,10 @@ export default class extends Controller {
this.#formatTime(this.daysagoFormatter, target)
}
agoorweekdayTargetConnected(target) {
this.#formatTime(this.agoorweekdayFormatter, target)
}
#refreshRelativeTimes() {
this.agoTargets.forEach(target => {
this.#formatTime(this.agoFormatter, target)
@@ -111,6 +120,18 @@ class DaysAgoFormatter {
}
}
class DaysAgoOrWeekdayFormatter {
format(date) {
const days = differenceInDays(date, new Date())
if (days <= 1) {
return new DaysAgoFormatter().format(date)
} else {
return new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" }).format(date)
}
}
}
class InDaysFormatter {
format(date) {
const days = differenceInDays(new Date(), date)
@@ -12,7 +12,7 @@ export default class extends Controller {
for (const file of files) {
const uploadEvent = new CustomEvent("house-md:before-upload", {
cards: true,
bubbles: true,
detail: { file },
cancelable: true
})
@@ -1,10 +1,10 @@
import { Controller } from "@hotwired/stimulus"
import { nextEventNamed } from "helpers/timing_helpers"
import { isTouchDevice } from "helpers/navigator_helpers"
export default class extends Controller {
// Only load for touch devices
static get shouldLoad() {
return isTouchDevice()
return "ontouchstart" in window && navigator.maxTouchPoints > 0
}
// Use a fake input to trigger the soft keyboard on actions that load async content
@@ -6,4 +6,12 @@ export default class extends Controller {
toggle() {
this.element.classList.toggle(this.toggleClass)
}
add() {
this.element.classList.add(this.toggleClass)
}
remove() {
this.element.classList.remove(this.toggleClass)
}
}
@@ -1,3 +0,0 @@
export function isTouchDevice() {
return "ontouchstart" in window && navigator.maxTouchPoints > 0
}
@@ -1,8 +0,0 @@
import { Turbo } from "@hotwired/turbo-rails"
Turbo.StreamActions.set_css_variable = function() {
const name = this.getAttribute("name")
const value = this.getAttribute("value")
this.targetElements.forEach(element => element.style.setProperty(name, value))
}
-1
View File
@@ -1,2 +1 @@
import "initializers/current"
import "initializers/custom_stream_actions"
-2
View File
@@ -1,6 +1,4 @@
class Card::AutoCloseAllDueJob < ApplicationJob
queue_as :default
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.auto_close_all_due
@@ -1,6 +1,4 @@
class Card::AutoReconsiderAllStagnatedJob < ApplicationJob
queue_as :default
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.auto_reconsider_all_stagnated
-7
View File
@@ -1,7 +0,0 @@
class GenerateNotificationsJob < ApplicationJob
queue_as :default
def perform(event)
event.generate_notifications
end
end
+5
View File
@@ -0,0 +1,5 @@
class Mention::CreateJob < ApplicationJob
def perform(record, mentioner:)
record.create_mentions(mentioner:)
end
end
+5
View File
@@ -0,0 +1,5 @@
class NotifyRecipientsJob < ApplicationJob
def perform(notifiable)
notifiable.notify_recipients
end
end
@@ -1,6 +1,4 @@
class RemoveAbandonedCreationsJob < ApplicationJob
queue_as :default
def perform
ApplicationRecord.with_each_tenant do |tenant|
Card.remove_abandoned_creations
+9 -52
View File
@@ -1,39 +1,26 @@
class Card < ApplicationRecord
include Assignable, Colored, DraftCommenting, Engageable, Eventable, Golden,
Messages, Notifiable, Pinnable, Closeable, Scorable, Searchable, Staged,
include Assignable, Colored, Engageable, Eventable, Golden, Mentions,
Messages, Pinnable, Closeable, Readable, Searchable, Staged,
Statuses, Taggable, Watchable
belongs_to :collection, touch: true
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :notifications, dependent: :destroy
has_one_attached :image, dependent: :purge_later
after_save :track_due_date_change, if: :saved_change_to_due_on?
after_save :track_title_change, if: :saved_change_to_title?
after_create :assign_initial_stage
has_markdown :description
scope :reverse_chronologically, -> { order created_at: :desc, id: :desc }
scope :chronologically, -> { order created_at: :asc, id: :asc }
scope :latest, -> { order updated_at: :desc, id: :desc }
scope :in_collection, ->(collection) { where collection: collection }
scope :indexed_by, ->(index) do
case index
when "most_active" then ordered_by_activity
when "newest" then reverse_chronologically
when "oldest" then chronologically
when "latest" then latest
when "stalled" then ordered_by_staleness
when "closed" then closed
end
end
scope :by_engagement_status, ->(status) do
case status.to_s
when "considering" then considering
when "doing" then doing.with_golden_first
when "newest" then reverse_chronologically
when "oldest" then chronologically
when "latest" then latest
when "stalled" then chronologically
when "closed" then closed
end
end
@@ -42,36 +29,6 @@ class Card < ApplicationRecord
end
def cache_key
[ super, collection&.name ].compact.join("/")
[ super, collection.name ].compact.join("/")
end
private
def track_due_date_change
if due_on.present?
if due_on_before_last_save.nil?
track_event("due_date_added", particulars: { due_date: due_on })
else
track_event("due_date_changed", particulars: { due_date: due_on })
end
elsif due_on_before_last_save.present?
track_event("due_date_removed")
end
end
def track_title_change
if title_before_last_save.present?
track_event("title_changed", particulars: {
old_title: title_before_last_save,
new_title: title
})
end
end
def assign_initial_stage
if workflow_stage = collection.workflow&.stages&.first
self.stage = workflow_stage
save! touch: false
track_event :staged, stage_id: workflow_stage.id, stage_name: workflow_stage.name
end
end
end
+7 -6
View File
@@ -1,16 +1,17 @@
module Card::Closeable
extend ActiveSupport::Concern
AUTO_CLOSE_AFTER = 30.days
included do
has_one :closure, dependent: :destroy
scope :closed, -> { joins(:closure) }
scope :active, -> { where.missing(:closure) }
scope :open, -> { where.missing(:closure) }
scope :recently_closed_first, -> { closed.order("closures.created_at": :desc) }
scope :due_to_be_closed, -> { considering.where(last_active_at: ..AUTO_CLOSE_AFTER.ago) }
scope :in_auto_closing_collection, -> { joins(:collection).merge(Collection.auto_closing) }
scope :due_to_be_closed, -> { considering.in_auto_closing_collection.where("last_active_at <= DATETIME('now', '-' || auto_close_period || ' seconds')") }
delegate :auto_closing?, :auto_close_period, to: :collection
end
class_methods do
@@ -22,14 +23,14 @@ module Card::Closeable
end
def auto_close_at
last_active_at + AUTO_CLOSE_AFTER if last_active_at
last_active_at + auto_close_period if auto_closing? && last_active_at
end
def closed?
closure.present?
end
def active?
def open?
!closed?
end
+4 -1
View File
@@ -1,8 +1,11 @@
module Card::Colored
extend ActiveSupport::Concern
COLORS = %w[ #3b3633 #67695e #eb7a32 #bf7c2b #c09c6f #746b1e #2c6da8 #5d618f #663251 #ff63a8 ]
DEFAULT_COLOR = "#2c6da8"
def color
color_from_stage || Colorable::DEFAULT_COLOR
color_from_stage || DEFAULT_COLOR
end
private
-33
View File
@@ -1,33 +0,0 @@
module Card::DraftCommenting
extend ActiveSupport::Concern
included do
after_save :capture_draft_comment
end
def draft_comment
find_or_build_initial_comment.body.content
end
def draft_comment=(body)
if body.present?
@draft_comment = body
else
messages.comments.destroy_all
end
end
private
def find_or_build_initial_comment
message = messages.comments.first || messages.new(messageable: Comment.new)
message.comment
end
def capture_draft_comment
if @draft_comment.present?
find_or_build_initial_comment.update! body: @draft_comment, creator: creator
end
@draft_comment = nil
end
end
+11 -4
View File
@@ -6,9 +6,16 @@ module Card::Engageable
included do
has_one :engagement, dependent: :destroy, class_name: "Card::Engagement"
scope :considering, -> { published_or_drafted_by(Current.user).active.where.missing(:engagement) }
scope :doing, -> { published.active.joins(:engagement) }
scope :considering, -> { published_or_drafted_by(Current.user).open.where.missing(:engagement) }
scope :doing, -> { published.open.joins(:engagement) }
scope :stagnated, -> { doing.where(last_active_at: ..STAGNATED_AFTER.ago) }
scope :by_engagement_status, ->(status) do
case status.to_s
when "considering" then considering
when "doing" then doing.with_golden_first
end
end
end
class_methods do
@@ -22,11 +29,11 @@ module Card::Engageable
end
def doing?
active? && published? && engagement.present?
open? && published? && engagement.present?
end
def considering?
active? && published? && engagement.blank?
open? && published? && engagement.blank?
end
def engage
+31 -11
View File
@@ -1,26 +1,46 @@
module Card::Eventable
extend ActiveSupport::Concern
include ::Eventable
included do
has_many :events, dependent: :destroy
before_create { self.last_active_at = Time.current }
after_save :track_due_date_change, if: :saved_change_to_due_on?
after_save :track_title_change, if: :saved_change_to_title?
end
def touch_last_active_at
touch :last_active_at
end
def track_event(action, creator: Current.user, **particulars)
if published?
event = find_or_capture_event_summary.events.create! action: action, creator: creator, card: self, particulars: particulars
event.generate_notifications_later
def event_was_created(event)
transaction do
find_or_capture_event_summary.events << event
touch(:last_active_at)
end
end
private
def should_track_event?
published?
end
def find_or_capture_event_summary
transaction do
messages.last&.event_summary || capture(EventSummary.new).event_summary
messages.last&.event_summary || capture(EventSummary.new).event_summary
end
def track_due_date_change
if due_on.present?
if due_on_before_last_save.nil?
track_event("card_due_date_added", particulars: { due_date: due_on })
else
track_event("card_due_date_changed", particulars: { due_date: due_on })
end
elsif due_on_before_last_save.present?
track_event("card_due_date_removed")
end
end
def track_title_change
if title_before_last_save.present?
track_event "card_title_changed", particulars: { old_title: title_before_last_save, new_title: title }
end
end
end
+1 -2
View File
@@ -2,10 +2,9 @@ module Card::Golden
extend ActiveSupport::Concern
included do
scope :golden, -> { joins(:goldness) }
has_one :goldness, dependent: :destroy, class_name: "Card::Goldness"
scope :golden, -> { joins(:goldness) }
scope :with_golden_first, -> { left_outer_joins(:goldness).prepend_order("card_goldnesses.id IS NULL") }
end
+38
View File
@@ -0,0 +1,38 @@
module Card::Readable
extend ActiveSupport::Concern
def read_by(user)
notifications_for(user).tap do |notifications|
notifications.each(&:read)
end
end
private
def notifications_for(user)
user.notifications.unread.where(source: notification_sources)
end
def notification_sources
event_notification_sources + mention_notification_sources
end
def event_notification_sources
events + comment_creation_events
end
def comment_creation_events
Event.where(eventable: comments)
end
def mention_notification_sources
mentions + comment_mentions
end
def comment_mentions
Mention.where(source: comments)
end
def comments
@comments ||= Comment.where(id: messages.comments.pluck(:messageable_id))
end
end
-64
View File
@@ -1,64 +0,0 @@
module Card::Scorable
extend ActiveSupport::Concern
REFERENCE_DATE = Time.utc(2025, 1, 1)
included do
scope :ordered_by_activity, -> { order activity_score_order: :desc }
# Staleness is measured by the amount of activity_score lost since it was last updated.
# The factor 0.9 is chosen to make the decay curve closer to linear; an exponential
# curve would make recent items appear stale very quickly, due to the sharp dropoff.
scope :ordered_by_staleness, -> { order Arel.sql(
"coalesce(activity_score * (1 - power(0.9, julianday('now') - julianday(activity_score_at))), 0) desc"
) }
end
def rescore
score = calculate_activity_score
score_at = last_scorable_activity_at
update! \
activity_score: score,
activity_score_at: score_at,
activity_score_order: event_score_reference(score, score_at)
end
private
def calculate_activity_score
scorable_events.sum { |event| event_score(event) }
end
def event_score(event)
days_ago = (last_scorable_activity_at - event.created_at) / 1.day
event_weight(event) / (2**days_ago)
end
def event_weight(event)
case
when event.comment&.first_by_author_on_card? then 20
when event.comment&.follows_comment_by_another_author? then 15
when event.commented? then 10
else 0
end
end
def event_score_reference(score, activity_at)
# The reference score is used to make the activity score comparable
# across different cards, since it represents the card's activity
# level at a consistent point in time.
#
# We store this as log2 to tame the huge/tiny numbers we'd otherwise get
# when activity is far from the reference date.
days_diff = (activity_at - REFERENCE_DATE) / 1.day
Math.log2(1.0 + score) + days_diff
end
def last_scorable_activity_at
scorable_events.maximum(:created_at) || created_at
end
def scorable_events
events.where(action: :commented)
end
end
+23 -17
View File
@@ -4,7 +4,7 @@ module Card::Searchable
included do
include ::Searchable
searchable_by :title, using: :cards_search_index
searchable_by :title_and_description, using: :cards_search_index, as: :title
scope :mentioning, ->(query) do
if query = sanitize_query_syntax(query)
@@ -18,25 +18,31 @@ module Card::Searchable
end
end
class_methods do
def sanitize_query_syntax(terms)
terms = terms.to_s
terms = remove_invalid_search_characters(terms)
terms = remove_unbalanced_quotes(terms)
terms.presence
class_methods do
def sanitize_query_syntax(terms)
terms = terms.to_s
terms = remove_invalid_search_characters(terms)
terms = remove_unbalanced_quotes(terms)
terms.presence
end
private
def remove_invalid_search_characters(terms)
terms.gsub(/[^\w"]/, " ")
end
private
def remove_invalid_search_characters(terms)
terms.gsub(/[^\w"]/, " ")
def remove_unbalanced_quotes(terms)
if terms.count("\"").even?
terms
else
terms.gsub("\"", " ")
end
end
end
def remove_unbalanced_quotes(terms)
if terms.count("\"").even?
terms
else
terms.gsub("\"", " ")
end
end
private
# TODO: Temporary until we stabilize the search API
def title_and_description
[ title, description.to_plain_text ].join(" ")
end
end
+9 -4
View File
@@ -4,6 +4,8 @@ module Card::Staged
included do
belongs_to :stage, class_name: "Workflow::Stage", optional: true
before_create :assign_initial_stage
scope :in_stage, ->(stage) { where stage: stage }
end
@@ -11,12 +13,15 @@ module Card::Staged
stage&.workflow
end
def toggle_stage(stage)
new_stage, event = self.stage_id == stage.id ? [ nil, :unstaged ] : [ stage, :staged ]
def change_stage_to(new_stage)
transaction do
update! stage: new_stage
track_event event, stage_id: stage.id, stage_name: stage.name
track_event :staged, stage_id: new_stage.id, stage_name: new_stage.name
end
end
private
def assign_initial_stage
self.stage = collection.initial_workflow_stage
end
end
+2 -11
View File
@@ -1,20 +1,11 @@
class Collection < ApplicationRecord
include Accessible, Broadcastable, Filterable
include AutoClosing, Accessible, Broadcastable, Filterable, Workflowing
belongs_to :creator, class_name: "User", default: -> { Current.user }
belongs_to :workflow, optional: true
has_many :cards, dependent: :destroy
has_many :tags, -> { distinct }, through: :cards
validates_presence_of :name
after_save :update_cards_workflow, if: :saved_change_to_workflow_id?
has_many :events
scope :alphabetically, -> { order("lower(name)") }
private
def update_cards_workflow
cards.update_all(stage_id: workflow&.stages&.first&.id)
end
end
+19
View File
@@ -0,0 +1,19 @@
module Collection::AutoClosing
extend ActiveSupport::Concern
included do
scope :auto_closing, -> { where.not(auto_close_period: nil) }
before_create :set_default_auto_close_period
end
def auto_closing?
auto_close_period.present?
end
private
DEFAULT_AUTO_CLOSE_PERIOD = 30.days
def set_default_auto_close_period
self.auto_close_period ||= DEFAULT_AUTO_CLOSE_PERIOD unless attribute_present?(:auto_close_period)
end
end
+18
View File
@@ -0,0 +1,18 @@
module Collection::Workflowing
extend ActiveSupport::Concern
included do
belongs_to :workflow, optional: true
after_update_commit :set_all_cards_to_initial_workflow_stage, if: :saved_change_to_workflow_id?
end
def initial_workflow_stage
workflow&.stages&.first
end
private
def set_all_cards_to_initial_workflow_stage
cards.update_all(stage_id: initial_workflow_stage&.id)
end
end
-23
View File
@@ -1,23 +0,0 @@
module Colorable
extend ActiveSupport::Concern
# COLORS = %w[ #3b3633 #67695e #eb7a32 #bf7c2b #c09c6f #746b1e #2c6da8 #5d618f #663251 #ff63a8 ]
# DEFAULT_COLOR = "#2c6da8"
COLORS = [
"var(--color-card-1)",
"var(--color-card-2)",
"var(--color-card-3)",
"var(--color-card-4)",
"var(--color-card-5)",
"var(--color-card-6)",
"var(--color-card-7)",
"var(--color-card-8)",
"var(--color-card-9)",
]
DEFAULT_COLOR = "var(--color-card-6)"
included do
attribute :color, default: DEFAULT_COLOR
end
end
+4 -6
View File
@@ -1,5 +1,5 @@
class Comment < ApplicationRecord
include Messageable, Searchable
include Eventable, Mentions, Messageable, Searchable
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :reactions, dependent: :delete_all
@@ -10,9 +10,11 @@ class Comment < ApplicationRecord
# FIXME: Not a fan of this. Think all references to comment should come directly from the message.
scope :belonging_to_card, ->(card) { joins(:message).where(messages: { card_id: card.id }) }
after_create_commit :watch_card_by_creator, :track_commented_card
after_create_commit :watch_card_by_creator
after_destroy_commit :cleanup_events
delegate :watch_by, to: :card
def to_partial_path
"cards/#{super}"
end
@@ -22,10 +24,6 @@ class Comment < ApplicationRecord
card.watch_by creator
end
def track_commented_card
card.track_event :commented, comment_id: id
end
# FIXME: This isn't right. We need to introduce an eventable polymorphic association for this.
def cleanup_events
# Delete events that reference directly in particulars
+18
View File
@@ -0,0 +1,18 @@
module Comment::Eventable
extend ActiveSupport::Concern
include ::Eventable
included do
after_create_commit :track_creation
end
def event_was_created(event)
card.touch(:last_active_at)
end
private
def track_creation
track_event("created", collection: card.collection)
end
end
+3 -1
View File
@@ -9,7 +9,9 @@ module Filterable
end
private
# FIXME: This is too inefficient to have part of a destroy transaction.
# Need to find a way to use a job or a single query.
def remove_from_filters
filters.each { |filter| filter.resource_removed self }
filters.each { it.resource_removed self }
end
end
+46
View File
@@ -0,0 +1,46 @@
module Mentions
extend ActiveSupport::Concern
included do
has_many :mentions, as: :source, dependent: :destroy
has_many :mentionees, through: :mentions
after_save_commit :create_mentions_later, if: :mentionable_content_changed?
end
def create_mentions(mentioner: Current.user)
scan_mentionees.each do |mentionee|
mentionee.mentioned_by mentioner, at: self
end
end
def mentionable_content
markdown_associations.collect { send(it.name)&.to_plain_text }.compact.join(" ")
end
private
def scan_mentionees
scan_mentioned_handles.filter_map do |mention|
mentionable_users.find { |user| user.mentionable_handles.include?(mention) }
end
end
def scan_mentioned_handles
mentionable_content.scan(/(?<!\w)@(\w+)/).flatten.uniq(&:downcase)
end
def mentionable_users
collection.users
end
def markdown_associations
self.class.reflect_on_all_associations(:has_one).filter { it.klass == ActionText::Markdown }
end
def mentionable_content_changed?
markdown_associations.any? { send(it.name).content_previously_changed? }
end
def create_mentions_later
Mention::CreateJob.perform_later(self, mentioner: Current.user)
end
end
+2
View File
@@ -6,5 +6,7 @@ module Messageable
included do
has_one :message, as: :messageable, touch: true, dependent: :destroy
has_one :card, through: :message
delegate :collection, to: :card
end
end
+16 -1
View File
@@ -2,6 +2,21 @@ module Notifiable
extend ActiveSupport::Concern
included do
has_many :notifications, as: :resource, dependent: :destroy
has_many :notifications, as: :source, dependent: :destroy
after_create_commit :notify_recipients_later
end
def notify_recipients
Notifier.for(self)&.notify
end
def notifiable_target
self
end
private
def notify_recipients_later
NotifyRecipientsJob.perform_later self
end
end
+9 -15
View File
@@ -1,26 +1,20 @@
class Event < ApplicationRecord
include Particulars
include Notifiable, Particulars
belongs_to :collection
belongs_to :creator, class_name: "User"
belongs_to :summary, touch: true, class_name: "EventSummary"
belongs_to :card
has_one :message, through: :summary
has_one :comment, through: :message, source: :messageable, source_type: "Comment"
belongs_to :eventable, polymorphic: true
belongs_to :summary, touch: true, class_name: "EventSummary", optional: true
scope :chronologically, -> { order created_at: :asc, id: :desc }
after_create -> { card.touch_last_active_at }
after_create -> { eventable.event_was_created(self) }
def commented?
action == "commented"
def action
super.inquiry
end
def generate_notifications
Notifier.for(self)&.generate
end
def generate_notifications_later
GenerateNotificationsJob.perform_later self
def notifiable_target
eventable
end
end
+1 -5
View File
@@ -2,14 +2,10 @@ module Event::Particulars
extend ActiveSupport::Concern
included do
store_accessor :particulars, :assignee_ids, :comment_id, :stage_id, :stage_name
store_accessor :particulars, :assignee_ids, :stage_id, :stage_name
end
def assignees
@assignees ||= User.where id: assignee_ids
end
def comment
@comment ||= Comment.find_by id: comment_id
end
end
+9 -11
View File
@@ -13,25 +13,23 @@ class EventSummary < ApplicationRecord
def summarize(event)
case event.action
when "published"
"Added by #{event.creator.name}."
when "assigned"
when "card_assigned"
"Assigned to #{event.assignees.pluck(:name).to_sentence}."
when "unassigned"
when "card_unassigned"
"Unassigned from #{event.assignees.pluck(:name).to_sentence}."
when "staged"
when "card_staged"
"#{event.creator.name} moved this to '#{event.stage_name}'."
when "closed"
when "card_closed"
"Closed by #{ event.creator.name }"
when "unstaged"
when "card_unstaged"
"#{event.creator.name} removed this from '#{event.stage_name}'."
when "due_date_added"
when "card_due_date_added"
"#{event.creator.name} set due date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')}."
when "due_date_changed"
when "card_due_date_changed"
"#{event.creator.name} changed due date to #{event.particulars.dig('particulars', 'due_date').to_date.strftime('%B %-d')}."
when "due_date_removed"
when "card_due_date_removed"
"#{event.creator.name} removed the date."
when "title_changed"
when "card_title_changed"
"#{event.creator.name} changed title from '#{event.particulars.dig('particulars', 'old_title')}' to '#{event.particulars.dig('particulars', 'new_title')}'."
end
end
+25
View File
@@ -0,0 +1,25 @@
module Eventable
extend ActiveSupport::Concern
included do
has_many :events, as: :eventable, dependent: :destroy
end
def track_event(action, creator: Current.user, collection: self.collection, **particulars)
if should_track_event?
collection.events.create!(action: "#{eventable_prefix}_#{action}", creator:, collection:, eventable: self, particulars:)
end
end
def event_was_created(event)
end
private
def should_track_event?
true
end
def eventable_prefix
self.class.name.demodulize.underscore
end
end
+2 -2
View File
@@ -19,12 +19,12 @@ class Filter < ApplicationRecord
def cards
@cards ||= begin
result = creator.accessible_cards.indexed_by(indexed_by)
result = result.active unless indexed_by.closed?
result = result.open unless indexed_by.closed?
result = result.by_engagement_status(engagement_status) if engagement_status.present?
result = result.unassigned if assignment_status.unassigned?
result = result.assigned_to(assignees.ids) if assignees.present?
result = result.where(creator_id: creators.ids) if creators.present?
result = result.in_collection(collections.ids) if collections.present?
result = result.where(collection: collections.ids) if collections.present?
result = result.in_stage(stages.ids) if stages.present?
result = result.tagged_with(tags.ids) if tags.present?
result = terms.reduce(result) do |result, term|
+22
View File
@@ -0,0 +1,22 @@
class Mention < ApplicationRecord
include Notifiable
belongs_to :source, polymorphic: true
belongs_to :mentioner, class_name: "User"
belongs_to :mentionee, class_name: "User", inverse_of: :mentions
after_create_commit :watch_source_by_mentionee
def self_mention?
mentioner == mentionee
end
def notifiable_target
source
end
private
def watch_source_by_mentionee
source.watch_by(mentionee)
end
end
+4 -4
View File
@@ -1,16 +1,16 @@
class Notification < ApplicationRecord
belongs_to :user
belongs_to :event
belongs_to :card
belongs_to :resource, polymorphic: true
belongs_to :creator, class_name: "User"
belongs_to :source, polymorphic: true
scope :unread, -> { where(read_at: nil) }
scope :read, -> { where.not(read_at: nil) }
scope :ordered, -> { order(read_at: :desc, created_at: :desc) }
delegate :creator, to: :event
after_create_commit :broadcast_unread
delegate :notifiable_target, to: :source
def self.read_all
update!(read_at: Time.current)
end
+13 -22
View File
@@ -1,40 +1,31 @@
class Notifier
attr_reader :event
delegate :creator, to: :event
attr_reader :source
class << self
def for(event)
"Notifier::#{event.action.classify}".safe_constantize&.new(event)
def for(source)
case source
when Event
"Notifier::#{source.eventable.class}EventNotifier".safe_constantize&.new(source)
when Mention
MentionNotifier.new(source)
end
end
end
def generate
def notify
if should_notify?
recipients.map do |recipient|
Notification.create! user: recipient, event: event, card: card, resource: resource
Notification.create! user: recipient, source: source, creator: creator
end
end
end
private
def initialize(event)
@event = event
def initialize(source)
@source = source
end
def should_notify?
!event.creator.system?
end
def recipients
card.watchers_and_subscribers.without(creator)
end
def resource
card
end
def card
event.summary.message.card
!creator.system?
end
end
-6
View File
@@ -1,6 +0,0 @@
class Notifier::Assigned < Notifier
private
def recipients
event.assignees.excluding(card.collection.access_only_users)
end
end
@@ -0,0 +1,22 @@
class Notifier::CardEventNotifier < Notifier
delegate :creator, to: :source
delegate :watchers_and_subscribers, to: :card
private
def recipients
case source.action
when "card_assigned"
source.assignees.excluding(source.collection.access_only_users)
when "card_published"
watchers_and_subscribers(include_only_watching: true).without(creator, *card.mentionees)
when "comment_created"
watchers_and_subscribers.without(creator, *source.eventable.mentionees)
else
watchers_and_subscribers.without(creator)
end
end
def card
source.eventable
end
end
-2
View File
@@ -1,2 +0,0 @@
class Notifier::Closed < Notifier
end
@@ -0,0 +1,12 @@
class Notifier::CommentEventNotifier < Notifier
delegate :creator, to: :source
private
def recipients
card.watchers_and_subscribers.without(creator, *source.eventable.mentionees)
end
def card
source.eventable.card
end
end

Some files were not shown because too many files have changed in this diff Show More