diff --git a/.gitignore b/.gitignore
index 05d3d1dc0..ab6207c8c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,3 +35,4 @@
/config/master.key
/config/credentials/*.key
+.DS_Store
diff --git a/app/assets/stylesheets/dividers.css b/app/assets/stylesheets/dividers.css
new file mode 100644
index 000000000..3b755ae36
--- /dev/null
+++ b/app/assets/stylesheets/dividers.css
@@ -0,0 +1,19 @@
+@layer components {
+ .divider {
+ --divider-padding: var(--inline-space);
+ --divider-color: var(--color-ink-light);
+
+ align-items: center;
+ display: flex;
+ margin-inline: calc(var(--divider-padding) * -1);
+
+ &:before,
+ &:after {
+ background: var(--divider-color);
+ block-size: var(--divider-size, 1px);
+ content: "";
+ flex: 1;
+ margin: 0 var(--divider-padding);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/assets/stylesheets/knobs.css b/app/assets/stylesheets/knobs.css
new file mode 100644
index 000000000..6014eb345
--- /dev/null
+++ b/app/assets/stylesheets/knobs.css
@@ -0,0 +1,142 @@
+@layer components {
+ .knob {
+ --knob-angle-reserve: 120deg;
+ --knob-option-angle: calc((360deg - var(--knob-angle-reserve)) / (var(--knob-options) - 1));
+ --knob-option-size: 3ch;
+ --knob-chamfer-size: 1ch;
+ --knob-color: oklch(var(--lch-ink-light));
+ --knob-color-accent: oklch(var(--lch-blue-medium));
+ --knob-tick-size: 1ch;
+ --knob-radius: calc(var(--knob-size) / 2);
+ --knob-size: 96px;
+
+ border: none;
+ display: block;
+ font-weight: 500;
+ padding: var(--knob-option-size) 0 0;
+ position: relative;
+ text-align: center;
+ }
+
+ .knob__slider {
+ --hover-size: 0;
+
+ appearance: none;
+ background-color: transparent;
+ block-size: var(--knob-size);
+ inline-size: var(--knob-size);
+ inset: 50% auto auto 50%;
+ position: absolute;
+ translate: -50% -50%;
+ z-index: 1;
+
+ &::-webkit-slider-runnable-track {
+ block-size: var(--knob-size);
+ cursor: grab;
+ }
+
+ &::-webkit-slider-thumb {
+ appearance: none;
+ background-color: transparent;
+ height: 1px;
+ width: 1px;
+ }
+ }
+
+ .knob__option {
+ block-size: var(--knob-option-size);
+ border-radius: 50%;
+ cursor: pointer;
+ display: grid;
+ inline-size: var(--knob-option-size);
+ inset: 50% auto auto 50%;
+ place-content: center;
+ position: absolute;
+ transform:
+ translate(-50%, -50%)
+ rotate(calc(-1 * ((360deg - var(--knob-angle-reserve)) / 2) + (var(--knob-option-angle) * var(--i))))
+ translateY(calc(-1 * var(--knob-radius) - 50% - var(--knob-tick-size)));
+ z-index: 1;
+
+ &:hover,
+ &:has(input:checked) {
+ color: var(--knob-color-accent);
+ }
+
+ &:has(:focus-visible) {
+ box-shadow: 0 0 0 2px var(--knob-color);
+ }
+
+ /* Tick marks */
+ &:before {
+ background-color: var(--knob-color);
+ block-size: var(--knob-tick-size);
+ content: "";
+ inline-size: 2px;
+ inset: 100% auto auto 50%;
+ position: absolute;
+ translate: -50% 0;
+ }
+
+ /* The value text */
+ span {
+ rotate: calc(((360deg - var(--knob-angle-reserve)) / 2) - (var(--knob-option-angle) * var(--i)));
+ }
+
+ input {
+ opacity: 0;
+ position: absolute;
+ }
+ }
+
+ .knob__knob {
+ background: linear-gradient(to top, var(--knob-color), color-mix(in oklch, var(--knob-color) 50%, var(--color-canvas) 50%));
+ block-size: var(--knob-size);
+ border-radius: 50%;
+ box-shadow:
+ 0 0 2px 1px rgba(0,0,0,0.10),
+ 0 2px 4px rgba(0,0,0,0.15),
+ 0 2px 8px rgba(0,0,0,0.20);
+ inline-size: var(--knob-size);
+ margin-inline: auto;
+ position: relative;
+
+ &:before,
+ &:after {
+ content: "";
+ position: absolute;
+ }
+
+ /* Indent */
+ &:before {
+ background: linear-gradient(to bottom, var(--knob-color), color-mix(in oklch, var(--knob-color) 50%, var(--color-canvas) 50%));
+ block-size: calc(var(--knob-size) - var(--knob-chamfer-size));
+ border-radius: 50%;
+ box-shadow:
+ 0 -1px 0 rgba(255, 255, 255, 0.25),
+ inset 0 -1px 0 rgba(255, 255, 255, 0.25);
+ inline-size: calc(var(--knob-size) - var(--knob-chamfer-size));
+ inset: 50% auto auto 50%;
+ translate: -50% -50%;
+ }
+
+ /* Indicator */
+ &:after {
+ background-color: var(--color-ink);
+ block-size: calc(var(--knob-radius) - var(--knob-chamfer-size) / 2);
+ border-radius: 50% 50% 2px 2px;
+ inline-size: 4px;
+ inset: auto auto 50% 50%;
+ rotate: calc(-1 * ((360deg - var(--knob-angle-reserve)) / 2) + (var(--knob-option-angle) * var(--knob-index)));
+ transform-origin: center bottom;
+ transition: rotate 100ms;
+ translate: -50% 0;
+ }
+ }
+
+ .knob__label {
+ font-weight: bold;
+ margin-block-start: 1ch;
+ text-transform: uppercase;
+ }
+}
diff --git a/app/controllers/accounts/entropy_configurations_controller.rb b/app/controllers/accounts/entropy_configurations_controller.rb
new file mode 100644
index 000000000..07e828242
--- /dev/null
+++ b/app/controllers/accounts/entropy_configurations_controller.rb
@@ -0,0 +1,12 @@
+class Accounts::EntropyConfigurationsController < ApplicationController
+ def update
+ Entropy::Configuration.default.update!(entropy_configuration_params)
+
+ redirect_to account_settings_path, notice: "Account updated"
+ end
+
+ private
+ def entropy_configuration_params
+ params.expect(entropy_configuration: [ :auto_close_period, :auto_reconsider_period ])
+ end
+end
diff --git a/app/controllers/accounts/settings_controller.rb b/app/controllers/accounts/settings_controller.rb
new file mode 100644
index 000000000..60e60ceaf
--- /dev/null
+++ b/app/controllers/accounts/settings_controller.rb
@@ -0,0 +1,6 @@
+class Accounts::SettingsController < ApplicationController
+ def show
+ @account = Account.sole
+ @users = User.active
+ end
+end
diff --git a/app/controllers/collections_controller.rb b/app/controllers/collections_controller.rb
index f5eb8c9fc..aaf71e046 100644
--- a/app/controllers/collections_controller.rb
+++ b/app/controllers/collections_controller.rb
@@ -33,7 +33,7 @@ class CollectionsController < ApplicationController
end
def collection_params
- params.expect(collection: [ :name, :all_access, :auto_close_period ])
+ params.expect(collection: [ :name, :all_access, :auto_close_period, :auto_reconsider_period ])
end
def grantees
diff --git a/app/controllers/concerns/user_timeline_scoped.rb b/app/controllers/concerns/user_timeline_scoped.rb
deleted file mode 100644
index 6e46ce8d1..000000000
--- a/app/controllers/concerns/user_timeline_scoped.rb
+++ /dev/null
@@ -1,12 +0,0 @@
-module UserTimelineScoped
- extend ActiveSupport::Concern
-
- included do
- include FilterScoped
- end
-
- def show
- @filter = Current.user.filters.new(creator_ids: [ @user.id ])
- @day_timeline = Current.user.timeline_for(Time.current, filter: @filter)
- end
-end
diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb
index f144f5be7..82b46b9a3 100644
--- a/app/controllers/users_controller.rb
+++ b/app/controllers/users_controller.rb
@@ -1,15 +1,10 @@
class UsersController < ApplicationController
- include UserTimelineScoped
require_unauthenticated_access only: %i[ new create ]
before_action :set_user, only: %i[ show edit update destroy ]
before_action :ensure_join_code_is_valid, only: %i[ new create ]
before_action :ensure_permission_to_change_user, only: %i[ update destroy ]
- def index
- @users = User.active
- end
-
def new
@user = User.new
end
@@ -23,6 +18,11 @@ class UsersController < ApplicationController
def edit
end
+ def show
+ @filter = Current.user.filters.new(creator_ids: [ @user.id ])
+ @day_timeline = Current.user.timeline_for(Time.current, filter: @filter)
+ end
+
def update
@user.update! user_params
redirect_to @user
diff --git a/app/helpers/cards_helper.rb b/app/helpers/cards_helper.rb
index e34a51d3d..ff760794f 100644
--- a/app/helpers/cards_helper.rb
+++ b/app/helpers/cards_helper.rb
@@ -56,4 +56,8 @@ module CardsHelper
"Closes"
end
end
+
+ def cacheable_preview_parts_for(card)
+ [ card, card.collection.workflow, Entropy::Configuration.default ]
+ end
end
diff --git a/app/helpers/collections_helper.rb b/app/helpers/collections_helper.rb
deleted file mode 100644
index 2155b4ae5..000000000
--- a/app/helpers/collections_helper.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-module CollectionsHelper
- def collection_auto_close_options
- [
- [ "3 days", 3.days ],
- [ "7 days", 7.days ],
- [ "30 days", 30.days ],
- [ "90 days", 90.days ],
- [ "365 days", 365.days ]
- ]
- end
-
- def collection_stalled_options
- [
- [ "1 day", 1.days ],
- [ "2 days", 2.days ],
- [ "3 days", 3.days ],
- [ "7 days", 7.days ],
- [ "14 days", 14.days ],
- [ "30 days", 30.days ],
- [ "365 days", 365.days ]
- ]
- end
-end
diff --git a/app/helpers/entropy_helper.rb b/app/helpers/entropy_helper.rb
new file mode 100644
index 000000000..b4f4d25ba
--- /dev/null
+++ b/app/helpers/entropy_helper.rb
@@ -0,0 +1,5 @@
+module EntropyHelper
+ def entropy_auto_close_options
+ [ 3, 7, 11, 30, 90, 365 ]
+ end
+end
diff --git a/app/javascript/controllers/knob_controller.js b/app/javascript/controllers/knob_controller.js
new file mode 100644
index 000000000..0ca225750
--- /dev/null
+++ b/app/javascript/controllers/knob_controller.js
@@ -0,0 +1,38 @@
+import { Controller } from "@hotwired/stimulus"
+
+export default class extends Controller {
+ static targets = [ "field", "option", "slider" ]
+
+ connect() {
+ this.#index = this.#selectedOption.dataset.index
+ }
+
+ optionChanged({ target }) {
+ this.#index = target.dataset.index
+ }
+
+ sliderChanged({ target }) {
+ this.#index = target.value
+ }
+
+ set #index(index) {
+ this.fieldTarget.style.setProperty("--knob-index", `${index}`);
+ this.sliderTarget.value = index
+ }
+
+ get #selectedOption() {
+ return this.optionTargets.find(option => {
+ return option.checked
+ })
+ }
+
+ set #value(index) {
+ this.#optionForIndex(index).checked = true
+ }
+
+ #optionForIndex(index) {
+ return this.optionTargets.find(option => {
+ return option.dataset.index === index;
+ })
+ }
+}
diff --git a/app/models/account.rb b/app/models/account.rb
index f562ef665..afd022441 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -1,5 +1,5 @@
class Account < ApplicationRecord
- include Joinable
+ include Entropic, Joinable
has_many_attached :uploads
end
diff --git a/app/models/account/entropic.rb b/app/models/account/entropic.rb
new file mode 100644
index 000000000..a60af2b4b
--- /dev/null
+++ b/app/models/account/entropic.rb
@@ -0,0 +1,18 @@
+module Account::Entropic
+ extend ActiveSupport::Concern
+
+ included do
+ has_one :default_entropy_configuration, class_name: "Entropy::Configuration", as: :container, dependent: :destroy
+
+ before_save :set_default_entropy_configuration
+ end
+
+ private
+ DEFAULT_ENTROPY_PERIOD = 30.days
+
+ def set_default_entropy_configuration
+ self.default_entropy_configuration ||= build_default_entropy_configuration \
+ auto_close_period: DEFAULT_ENTROPY_PERIOD,
+ auto_reconsider_period: DEFAULT_ENTROPY_PERIOD
+ end
+end
diff --git a/app/models/card.rb b/app/models/card.rb
index 656a51b12..0a109e5ba 100644
--- a/app/models/card.rb
+++ b/app/models/card.rb
@@ -1,5 +1,5 @@
class Card < ApplicationRecord
- include Assignable, Colored, Engageable, Entropy, Eventable,
+ include Assignable, Colored, Engageable, Entropic, Eventable,
Golden, Mentions, Pinnable, Closeable, Readable, Searchable,
Staged, Statuses, Taggable, Watchable
diff --git a/app/models/card/entropic.rb b/app/models/card/entropic.rb
new file mode 100644
index 000000000..56066491a
--- /dev/null
+++ b/app/models/card/entropic.rb
@@ -0,0 +1,37 @@
+module Card::Entropic
+ extend ActiveSupport::Concern
+
+ ENTROPY_REMINDER_BEFORE = 7.days
+
+ included do
+ scope :entropic_by, ->(period_name) do
+ left_outer_joins(collection: :entropy_configuration)
+ .where("last_active_at <= DATETIME('now', '-' || COALESCE(entropy_configurations.#{period_name}, ?) || ' seconds')",
+ Entropy::Configuration.default.public_send(period_name))
+ end
+
+ scope :stagnated, -> { doing.entropic_by(:auto_reconsider_period) }
+ scope :due_to_be_closed, -> { considering.entropic_by(:auto_close_period) }
+ delegate :auto_close_period, :auto_reconsider_period, to: :collection
+ end
+
+ class_methods do
+ def auto_close_all_due
+ due_to_be_closed.find_each do |card|
+ card.close(user: User.system, reason: "Closed")
+ end
+ end
+
+ def auto_reconsider_all_stagnated
+ stagnated.find_each(&:reconsider)
+ end
+ end
+
+ def entropy
+ Card::Entropy.for(self)
+ end
+
+ def entropic?
+ entropy.present?
+ end
+end
diff --git a/app/models/card/entropy.rb b/app/models/card/entropy.rb
index 0b6f6e4aa..c2cc45e10 100644
--- a/app/models/card/entropy.rb
+++ b/app/models/card/entropy.rb
@@ -1,59 +1,24 @@
-module Card::Entropy
- extend ActiveSupport::Concern
+class Card::Entropy
+ attr_reader :card, :auto_clean_period
- AUTO_RECONSIDER_PERIOD = 30.days
- ENTROPY_REMINDER_BEFORE = 7.days
+ class << self
+ def for(card)
+ return unless card.last_active_at
- included do
- scope :in_auto_closing_collection, -> { joins(:collection).merge(Collection.auto_closing) }
-
- scope :stagnated, -> { doing.where(last_active_at: ..AUTO_RECONSIDER_PERIOD.ago) }
- scope :due_to_be_closed, -> { considering.in_auto_closing_collection.where("last_active_at <= DATETIME('now', '-' || auto_close_period || ' seconds')") }
-
- delegate :auto_close_period, to: :collection
- end
-
- class_methods do
- def auto_close_all_due
- due_to_be_closed.find_each do |card|
- card.close(user: User.system, reason: "Closed")
+ if card.considering?
+ new(card, card.auto_close_period)
+ elsif card.doing?
+ new(card, card.auto_reconsider_period)
end
end
-
- def auto_reconsider_all_stagnated
- stagnated.find_each(&:reconsider)
- end
end
- def subject_to_entropy?
- auto_reconsidering? || auto_closing?
+ def initialize(card, auto_clean_period)
+ @card = card
+ @auto_clean_period = auto_clean_period
end
- def auto_reconsidering?
- doing? && last_active_at
- end
-
- def auto_closing?
- considering? && collection.auto_closing? && last_active_at
- end
-
- def auto_close_at
- last_active_at + auto_close_period if auto_closing?
- end
-
- def days_until_close
- (auto_close_at.to_date - Date.current).to_i if auto_close_at
- end
-
- def auto_reconsider_at
- last_active_at + AUTO_RECONSIDER_PERIOD if auto_reconsidering?
- end
-
- def days_until_reconsider
- (auto_reconsider_at.to_date - Date.current).to_i if auto_reconsider_at
- end
-
- def entropy_cleaned_at
- auto_close_at || auto_reconsider_at
+ def auto_clean_at
+ card.last_active_at + auto_clean_period
end
end
diff --git a/app/models/collection.rb b/app/models/collection.rb
index 9b74d8524..105cd6eed 100644
--- a/app/models/collection.rb
+++ b/app/models/collection.rb
@@ -1,11 +1,12 @@
class Collection < ApplicationRecord
- include AutoClosing, Accessible, Broadcastable, Filterable, Workflowing
+ include AutoClosing, Accessible, Broadcastable, Entropic, Filterable, Workflowing
belongs_to :creator, class_name: "User", default: -> { Current.user }
has_many :cards, dependent: :destroy
has_many :tags, -> { distinct }, through: :cards
has_many :events
+ has_one :entropy_configuration, class_name: "Entropy::Configuration", as: :container, dependent: :destroy
scope :alphabetically, -> { order("lower(name)") }
diff --git a/app/models/collection/auto_closing.rb b/app/models/collection/auto_closing.rb
index e3a9c8990..5f000c48b 100644
--- a/app/models/collection/auto_closing.rb
+++ b/app/models/collection/auto_closing.rb
@@ -2,14 +2,9 @@ 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
diff --git a/app/models/collection/entropic.rb b/app/models/collection/entropic.rb
new file mode 100644
index 000000000..9a4a6b362
--- /dev/null
+++ b/app/models/collection/entropic.rb
@@ -0,0 +1,21 @@
+module Collection::Entropic
+ extend ActiveSupport::Concern
+
+ included do
+ delegate :auto_close_period, :auto_reconsider_period, to: :entropy_configuration
+ end
+
+ def entropy_configuration
+ super || Entropy::Configuration.default
+ end
+
+ def auto_close_period=(new_value)
+ entropy_configuration ||= association(:entropy_configuration).reader || self.build_entropy_configuration
+ entropy_configuration.update auto_close_period: new_value
+ end
+
+ def auto_reconsider_period=(new_value)
+ entropy_configuration ||= association(:entropy_configuration).reader || self.build_entropy_configuration
+ entropy_configuration.update auto_reconsider_period: new_value
+ end
+end
diff --git a/app/models/entropy.rb b/app/models/entropy.rb
new file mode 100644
index 000000000..42e730a09
--- /dev/null
+++ b/app/models/entropy.rb
@@ -0,0 +1,5 @@
+module Entropy
+ def self.table_name_prefix
+ "entropy_"
+ end
+end
diff --git a/app/models/entropy/configuration.rb b/app/models/entropy/configuration.rb
new file mode 100644
index 000000000..4ed13d597
--- /dev/null
+++ b/app/models/entropy/configuration.rb
@@ -0,0 +1,9 @@
+class Entropy::Configuration < ApplicationRecord
+ belongs_to :container, polymorphic: true
+
+ class << self
+ def default
+ Account.sole.default_entropy_configuration
+ end
+ end
+end
diff --git a/app/views/users/_invite.html.erb b/app/views/accounts/_invite.html.erb
similarity index 95%
rename from app/views/users/_invite.html.erb
rename to app/views/accounts/_invite.html.erb
index d6e99af78..4bc7562f0 100644
--- a/app/views/users/_invite.html.erb
+++ b/app/views/accounts/_invite.html.erb
@@ -1,4 +1,4 @@
-
+
<% url = join_url(Account.sole.join_code) %>