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) %>