diff --git a/app/assets/images/bubbles.svg b/app/assets/images/bubbles.svg new file mode 100644 index 0000000000..bddf292cad --- /dev/null +++ b/app/assets/images/bubbles.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/bubble.css b/app/assets/stylesheets/bubble.css index 33dd732c24..fc875571fe 100644 --- a/app/assets/stylesheets/bubble.css +++ b/app/assets/stylesheets/bubble.css @@ -43,6 +43,18 @@ } } + .bubble--golden { + &:before { + box-shadow: + 0 0 0 1px color-mix(in oklch, var(--color-golden) 100%, transparent), + 0 0 0.2em 0.2em color-mix(in oklch, var(--color-golden) 25%, transparent); + background: radial-gradient( + color-mix(in srgb, var(--color-golden) 8%, var(--color-canvas)) 50%, + color-mix(in srgb, var(--color-golden) 48%, var(--color-canvas)) 100% + ); + } + } + .bubble__number { display: grid; font-size: clamp(10px, 50cqi, var(--bubble-number-max)); /* FF bug: https://app.fizzy.do/5986089/boards/2/cards/1373 */ diff --git a/app/assets/stylesheets/card-columns.css b/app/assets/stylesheets/card-columns.css index 1083c2ffc9..31ec902b0f 100644 --- a/app/assets/stylesheets/card-columns.css +++ b/app/assets/stylesheets/card-columns.css @@ -789,7 +789,7 @@ --card-color: var(--color-card-complete) !important; } - .bubble { + .bubble:not(.bubble--golden) { display: none !important; } } @@ -799,17 +799,13 @@ /* Surface a mini bubble if there are cards with bubbles inside */ .cards--maybe:has(.bubble:not([hidden])) .cards__expander-title, - .cards--maybe.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container, + .cards--on-deck:has(.bubble--golden:not([hidden])) .cards__expander-title, .cards--doing.is-collapsed:has(.bubble:not([hidden])) .cards__transition-container { - --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); --bubble-opacity: 75%; --bubble-shape: 54% 46% 61% 39% / 57% 49% 51% 43%; - &:before { - background: radial-gradient( - color-mix(in srgb, var(--bubble-color) calc(var(--bubble-opacity) / 5), var(--color-canvas)) 50%, - color-mix(in srgb, var(--bubble-color) var(--bubble-opacity), var(--color-canvas)) 100% - ); + &:before, + &:after { block-size: 1em; border-radius: var(--bubble-shape); content: ""; @@ -820,21 +816,53 @@ z-index: 1; } + /* Regular mini bubble */ + .cards:has(.bubble:not(.bubble--golden):not([hidden])) &::before { + --bubble-color: var(--card-color, oklch(var(--lch-blue-medium))); + background: radial-gradient( + color-mix(in srgb, var(--bubble-color) calc(var(--bubble-opacity) / 5), var(--color-canvas)) 50%, + color-mix(in srgb, var(--bubble-color) var(--bubble-opacity), var(--color-canvas)) 100% + ); + } + + /* Golden mini bubble */ + .cards:has(.bubble--golden:not([hidden])) &::after { + --bubble-color: var(--color-golden); + background: radial-gradient( + color-mix(in srgb, var(--bubble-color) calc(var(--bubble-opacity) / 5), var(--color-canvas)) 50%, + color-mix(in srgb, var(--bubble-color) var(--bubble-opacity), var(--color-canvas)) 100% + ); + } + + /* Offset if both bubbles present */ + .cards:has(.bubble:not(.bubble--golden):not([hidden])):has(.bubble--golden:not([hidden])) & { + &::before { translate: -15% -25%; } + &::after { translate: 35% -15%; } + } + /* Maybe column: position bubble relative to the title, not the container */ - .cards--maybe.is-expanded & { + .cards--maybe.is-expanded &, + .cards--on-deck.is-expanded & { overflow: visible; position: relative; - &:before { + &:before, + &:after { inset-block-start: 50%; inset-inline-start: 0; - translate: -125% -75%; + translate: -135% -50%; z-index: -1; } + + .cards:has(.bubble:not(.bubble--golden):not([hidden])):has(.bubble--golden:not([hidden])) & { + &::before { translate: -160% -50%; } + &::after { translate: -110% -50%; } + } } @media (max-width: 639px) { - &.cards__expander-title:before { + &.cards__expander-title:before, + &.cards__expander-title:after { display: none; } } diff --git a/app/assets/stylesheets/card-perma.css b/app/assets/stylesheets/card-perma.css index b3d37d68ac..cd6b90b4ad 100644 --- a/app/assets/stylesheets/card-perma.css +++ b/app/assets/stylesheets/card-perma.css @@ -248,7 +248,7 @@ &:has([open]) { position: relative; - z-index: 1; + z-index: 2; } &:has([data-controller~="tooltip"]:hover) { diff --git a/app/assets/stylesheets/cards.css b/app/assets/stylesheets/cards.css index cbaa66c914..e2392dce01 100644 --- a/app/assets/stylesheets/cards.css +++ b/app/assets/stylesheets/cards.css @@ -407,7 +407,7 @@ .card-perma:has(.card--postponed) { --card-color: var(--color-card-complete) !important; - .bubble { + .bubble:not(.bubble--golden) { display: none; } } diff --git a/app/assets/stylesheets/icons.css b/app/assets/stylesheets/icons.css index 42a3a21ece..353f0e8a24 100644 --- a/app/assets/stylesheets/icons.css +++ b/app/assets/stylesheets/icons.css @@ -34,6 +34,7 @@ .icon--bookmark-outline { --svg: url("bookmark-outline.svg "); } .icon--bookmark { --svg: url("bookmark.svg "); } .icon--boost { --svg: url("boost.svg "); } + .icon--bubbles { --svg: url("bubbles.svg "); } .icon--camera { --svg: url("camera.svg "); } .icon--caret-down { --svg: url("caret-down.svg "); } .icon--check { --svg: url("check.svg "); } diff --git a/app/assets/stylesheets/popup.css b/app/assets/stylesheets/popup.css index aa0b8c1daf..228d6886e7 100644 --- a/app/assets/stylesheets/popup.css +++ b/app/assets/stylesheets/popup.css @@ -170,6 +170,7 @@ text-align: start; &:focus-visible { + outline: none; z-index: 1; } } diff --git a/app/controllers/cards/bubble_ups_controller.rb b/app/controllers/cards/bubble_ups_controller.rb new file mode 100644 index 0000000000..a969603822 --- /dev/null +++ b/app/controllers/cards/bubble_ups_controller.rb @@ -0,0 +1,21 @@ +class Cards::BubbleUpsController < ApplicationController + include CardScoped + + def create + @card.bubble_up_at TimeSlot.for(params[:slot]) + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end + end + + def destroy + @card.pop + + respond_to do |format| + format.turbo_stream { render_card_replacement } + format.json { head :no_content } + end + end +end diff --git a/app/helpers/bubble_up_helper.rb b/app/helpers/bubble_up_helper.rb new file mode 100644 index 0000000000..0f08281462 --- /dev/null +++ b/app/helpers/bubble_up_helper.rb @@ -0,0 +1,14 @@ +module BubbleUpHelper + def bubble_up_options_for(card) + if card.bubble_up? + { + isPostponed: card.postponed?, + resurfaceAt: card.bubble_up.resurface_at.iso8601 + } + end + end + + def slot_too_soon(slot) + slot == "latertoday" && Time.current.hour > 16 ? true : false + end +end diff --git a/app/javascript/controllers/bubble_controller.js b/app/javascript/controllers/bubble_controller.js index b8b7f65454..965ffb44a9 100644 --- a/app/javascript/controllers/bubble_controller.js +++ b/app/javascript/controllers/bubble_controller.js @@ -1,11 +1,11 @@ import { Controller } from "@hotwired/stimulus" -import { signedDifferenceInDays } from "helpers/date_helpers" +import { signedDifferenceInDays, differenceInDays } from "helpers/date_helpers" const REFRESH_INTERVAL = 3_600_000 // 1 hour (in milliseconds) export default class extends Controller { static targets = [ "entropy", "stalled", "top", "center", "bottom" ] - static values = { entropy: Object, stalled: Object } + static values = { entropy: Object, stalled: Object, up: Object } #timer @@ -23,7 +23,9 @@ export default class extends Controller { this.#showEntropy() } else if (this.#isStalled) { this.#showStalled() - } else { + } else if (this.#isBubbling) { + this.#showBubbling() + }else { this.#hide() } } @@ -67,6 +69,28 @@ export default class extends Controller { }) } + get #isBubbling() { + if (!this.upValue.resurfaceAt) return false + return this.upValue.isPostponed || this.#hasBubbled + } + + #showBubbling() { + const dayLabel = this.#resurfaceDayCount === 1 ? "day" : "days" + this.#render({ + top: this.#hasBubbled ? "Bubbled Up" : this.#resurfaceDayCount < 1 ? "Bubbles Up" : "Bubbles Up in", + center: this.#resurfaceDayCount < 1 ? "!" : this.#resurfaceDayCount, + bottom: this.#resurfaceDayCount < 1 ? "Today" : this.#hasBubbled ? `${dayLabel} ago` : dayLabel + }) + } + + get #hasBubbled() { + return new Date() > new Date(this.upValue.resurfaceAt) + } + + get #resurfaceDayCount() { + return differenceInDays(new Date(), new Date(this.upValue.resurfaceAt)) + } + #hide() { this.element.toggleAttribute("hidden", true) } diff --git a/app/models/card.rb b/app/models/card.rb index d422afa854..ffe1b41e78 100644 --- a/app/models/card.rb +++ b/app/models/card.rb @@ -1,5 +1,5 @@ class Card < ApplicationRecord - include Accessible, Assignable, Attachments, Broadcastable, Closeable, Colored, Commentable, + include Accessible, Assignable, Attachments, Broadcastable, BubblesUp, Closeable, Colored, Commentable, Entropic, Eventable, Exportable, Golden, Mentions, Multistep, Pinnable, Postponable, Promptable, Readable, Searchable, Stallable, Statuses, Storage::Tracked, Taggable, Triageable, Watchable diff --git a/app/models/card/bubble_up.rb b/app/models/card/bubble_up.rb new file mode 100644 index 0000000000..7c9c147633 --- /dev/null +++ b/app/models/card/bubble_up.rb @@ -0,0 +1,12 @@ +class Card::BubbleUp < ApplicationRecord + belongs_to :account, default: -> { card.account } + belongs_to :card, touch: true + + scope :due_to_resurface, -> { joins(card: :not_now).where(resurface_at: ..Time.now) } + + def self.resurface_all_due + due_to_resurface.find_each do |bubble_up| + bubble_up.card.send_back_to_triage(skip_event: true) + end + end +end diff --git a/app/models/card/bubbles_up.rb b/app/models/card/bubbles_up.rb new file mode 100644 index 0000000000..9659e72787 --- /dev/null +++ b/app/models/card/bubbles_up.rb @@ -0,0 +1,29 @@ +module Card::BubblesUp + extend ActiveSupport::Concern + + included do + has_one :bubble_up, dependent: :destroy, class_name: "Card::BubbleUp" + end + + def bubble_up? + bubble_up.present? + end + + def bubbling? + bubble_up? && Time.current.before?(bubble_up.resurface_at) + end + + def bubbled? + bubble_up? && Time.current.after?(bubble_up.resurface_at) + end + + def bubble_up_at(time) + postpone unless postponed? + bubble_up ||= association(:bubble_up).reader || self.build_bubble_up + bubble_up.update resurface_at: time + end + + def pop + bubble_up&.destroy + end +end diff --git a/app/models/card/closeable.rb b/app/models/card/closeable.rb index ff947452bb..dac3aa3055 100644 --- a/app/models/card/closeable.rb +++ b/app/models/card/closeable.rb @@ -32,6 +32,7 @@ def close(user: Current.user) unless closed? transaction do not_now&.destroy + bubble_up&.destroy create_closure! user: user track_event :closed, creator: user end diff --git a/app/models/card/postponable.rb b/app/models/card/postponable.rb index ab58960750..d6ac7794d3 100644 --- a/app/models/card/postponable.rb +++ b/app/models/card/postponable.rb @@ -34,6 +34,7 @@ def postpone(user: Current.user, event_name: :postponed) reopen activity_spike&.destroy create_not_now!(user: user) unless postponed? + pop if bubble_up? track_event event_name, creator: user end end diff --git a/app/models/card/triageable.rb b/app/models/card/triageable.rb index d156639022..ae9c45c5b1 100644 --- a/app/models/card/triageable.rb +++ b/app/models/card/triageable.rb @@ -21,6 +21,7 @@ def triage_into(column) transaction do resume + pop if bubble_up? update! column: column track_event "triaged", particulars: { column: column.name } end diff --git a/app/models/time_slot.rb b/app/models/time_slot.rb new file mode 100644 index 0000000000..7e7f55dd34 --- /dev/null +++ b/app/models/time_slot.rb @@ -0,0 +1,36 @@ +class TimeSlot + attr_reader :slot + + HUMAN_NAMES_BY_VALUE = { + "latertoday" => "Later Today", + "tomorrow" => "Tomorrow", + "thisweekend" => "This Weekend", + "nextweek" => "Next Week", + "surprise" => "Surprise Me" + } + + class << self + def for(slot) + new.for(slot) + end + + def initialize(slot) + @slot = slot + end + end + + def for(slot) + case slot + when "latertoday" + Time.current.change(hour: 18) + when "tomorrow" + 1.day.from_now.change(hour: 8) + when "thisweekend" + Date.current.next_occurring(:saturday).in_time_zone.change(hour: 8) + when "nextweek" + Date.current.next_occurring(:monday).in_time_zone.change(hour: 8) + when "surprise" + rand(2..14).days.from_now.change(hour: 8) + end + end +end diff --git a/app/views/cards/_container.html.erb b/app/views/cards/_container.html.erb index b6823bd768..1f26c64ba2 100644 --- a/app/views/cards/_container.html.erb +++ b/app/views/cards/_container.html.erb @@ -2,6 +2,7 @@ <% cache card do %>
<%= render "cards/container/gild", card: card if card.published? && !card.closed? %> + <%= render "cards/container/bubble_up", card: card if card.published? && !card.closed? %> <%= render "cards/container/image", card: card %>
@@ -32,7 +33,7 @@ <% end %> - <% if card.entropic? %> + <% if card.entropic? || card.bubble_up? %> <%= render "cards/display/preview/bubble", card: card %> <% end %> diff --git a/app/views/cards/container/_bubble_up.html.erb b/app/views/cards/container/_bubble_up.html.erb new file mode 100644 index 0000000000..69779bd563 --- /dev/null +++ b/app/views/cards/container/_bubble_up.html.erb @@ -0,0 +1,43 @@ +
+ + + + BUBBLE UP... + +
+ +
+
+
diff --git a/app/views/cards/display/_preview.html.erb b/app/views/cards/display/_preview.html.erb index fe8b7c6138..dd4ab2c56f 100644 --- a/app/views/cards/display/_preview.html.erb +++ b/app/views/cards/display/_preview.html.erb @@ -54,7 +54,7 @@ <%= render "cards/display/common/background", card: card %> - <% if card.entropic? %> + <% if card.entropic? || card.bubble_up? %> <%= render "cards/display/preview/bubble", card: card %> <% end %> <% end %> diff --git a/app/views/cards/display/_public_preview.html.erb b/app/views/cards/display/_public_preview.html.erb index 7aa53fed75..fe02a69c53 100644 --- a/app/views/cards/display/_public_preview.html.erb +++ b/app/views/cards/display/_public_preview.html.erb @@ -36,7 +36,7 @@ <%= card.title %> <% end %> - <% if card.entropic? %> + <% if card.entropic? || card.bubble_up? %> <%= render "cards/display/preview/bubble", card: card %> <% end %> diff --git a/app/views/cards/display/preview/_bubble.html.erb b/app/views/cards/display/preview/_bubble.html.erb index 40d8e4311f..f74664a72a 100644 --- a/app/views/cards/display/preview/_bubble.html.erb +++ b/app/views/cards/display/preview/_bubble.html.erb @@ -1,12 +1,13 @@ <%= tag.div \ id: dom_id(card, "bubble"), hidden: true, - class: "bubble", + class: class_names("bubble", "bubble--golden" => card.bubble_up?), data: { controller: "bubble", action: "turbo:morph-element->bubble#update:self", bubble_entropy_value: entropy_bubble_options_for(card).to_json, - bubble_stalled_value: stalled_bubble_options_for(card)&.to_json + bubble_stalled_value: stalled_bubble_options_for(card)&.to_json, + bubble_up_value: bubble_up_options_for(card)&.to_json } do %> diff --git a/config/recurring.yml b/config/recurring.yml index 8aba572ca4..ae716d5b25 100644 --- a/config/recurring.yml +++ b/config/recurring.yml @@ -10,6 +10,9 @@ production: &production auto_postpone_all_due: command: "Card.auto_postpone_all_due" schedule: every hour at minute 50 + bubble_up_all_due: + command: "Card::BubbleUp.resurface_all_due" + schedule: every hour delete_unused_tags: class: DeleteUnusedTagsJob schedule: every day at 04:02 diff --git a/config/routes.rb b/config/routes.rb index 9ca2eb05bb..8b77aa537b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -74,6 +74,7 @@ scope module: :cards do resource :draft, only: :show resource :board + resource :bubble_up resource :closure resource :column resource :goldness diff --git a/db/migrate/20260120173226_create_card_bubble_ups.rb b/db/migrate/20260120173226_create_card_bubble_ups.rb new file mode 100644 index 0000000000..ff58ee1b70 --- /dev/null +++ b/db/migrate/20260120173226_create_card_bubble_ups.rb @@ -0,0 +1,11 @@ +class CreateCardBubbleUps < ActiveRecord::Migration[8.2] + def change + create_table :card_bubble_ups, id: :uuid do |t| + t.references :account, null: false, type: :uuid + t.references :card, null: false, type: :uuid + t.datetime :resurface_at + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index fc83e3f580..5b83a7d1a1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -177,6 +177,16 @@ t.index ["card_id"], name: "index_card_activity_spikes_on_card_id", unique: true end + create_table "card_bubble_ups", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "resurface_at" + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_card_bubble_ups_on_account_id" + t.index ["card_id"], name: "index_card_bubble_ups_on_card_id" + end + create_table "card_goldnesses", id: :uuid, charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false diff --git a/db/schema_sqlite.rb b/db/schema_sqlite.rb index 817e37204b..25e36bdbe4 100644 --- a/db/schema_sqlite.rb +++ b/db/schema_sqlite.rb @@ -177,6 +177,16 @@ t.index ["card_id"], name: "index_card_activity_spikes_on_card_id", unique: true end + create_table "card_bubble_ups", id: :uuid, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "card_id", null: false + t.datetime "created_at", null: false + t.datetime "resurface_at" + t.datetime "updated_at", null: false + t.index ["account_id"], name: "index_card_bubble_ups_on_account_id" + t.index ["card_id"], name: "index_card_bubble_ups_on_card_id" + end + create_table "card_goldnesses", id: :uuid, force: :cascade do |t| t.uuid "account_id", null: false t.uuid "card_id", null: false diff --git a/test/controllers/cards/bubble_ups_controller_test.rb b/test/controllers/cards/bubble_ups_controller_test.rb new file mode 100644 index 0000000000..88220318ab --- /dev/null +++ b/test/controllers/cards/bubble_ups_controller_test.rb @@ -0,0 +1,46 @@ +require "test_helper" + +class Cards::BubbleUpsControllerTest < ActionDispatch::IntegrationTest + setup do + @account = accounts(:initech) + sign_in_as :mike + + integration_session.default_url_options[:script_name] = "/#{@account.external_account_id}" + end + + test "create" do + assert_changes -> { cards(:radio).reload.bubble_up? }, from: false, to: true do + post card_bubble_up_path(cards(:radio)), params: { slot: "tomorrow" }, as: :turbo_stream + assert_card_container_rerendered(cards(:radio)) + end + end + + test "destroy" do + assert_changes -> { cards(:postponed_idea).reload.bubble_up? }, from: true, to: false do + delete card_bubble_up_path(cards(:postponed_idea)), as: :turbo_stream + assert_card_container_rerendered(cards(:postponed_idea)) + end + end + + test "create as JSON" do + card = cards(:radio) + + assert_not card.bubble_up? + + post card_bubble_up_path(card), as: :json + + assert_response :no_content + assert card.reload.bubble_up? + end + + test "destroy as JSON" do + card = cards(:postponed_idea) + + assert card.bubble_up? + + delete card_bubble_up_path(card), as: :json + + assert_response :no_content + assert_not card.reload.bubble_up? + end +end diff --git a/test/fixtures/card/bubble_ups.yml b/test/fixtures/card/bubble_ups.yml new file mode 100644 index 0000000000..464a68653a --- /dev/null +++ b/test/fixtures/card/bubble_ups.yml @@ -0,0 +1,5 @@ +postponed_idea: + id: <%= ActiveRecord::FixtureSet.identify("postponed_idea_bubble_up", :uuid) %> + account: initech_uuid + card: postponed_idea_uuid + resurface_at: <%= Time.zone.tomorrow.change(hour: 8) %> diff --git a/test/fixtures/card/not_nows.yml b/test/fixtures/card/not_nows.yml new file mode 100644 index 0000000000..52419d88c2 --- /dev/null +++ b/test/fixtures/card/not_nows.yml @@ -0,0 +1,5 @@ +postponed_idea: + id: <%= ActiveRecord::FixtureSet.identify("postponed_idea_not_now", :uuid) %> + account: initech_uuid + card: postponed_idea_uuid + diff --git a/test/fixtures/cards.yml b/test/fixtures/cards.yml index 4785ee8d19..5c137fe751 100644 --- a/test/fixtures/cards.yml +++ b/test/fixtures/cards.yml @@ -90,3 +90,14 @@ unfinished_thoughts: status: drafted last_active_at: <%= 1.week.ago %> account: initech_uuid + +postponed_idea: + id: <%= ActiveRecord::FixtureSet.identify("postponed_idea", :uuid) %> + number: 4 + board: miltons_wish_list_uuid + creator: mike_uuid + title: Postponed Idea + created_at: <%= 1.week.ago %> + status: published + last_active_at: <%= 1.week.ago %> + account: initech_uuid diff --git a/test/helpers/bubble_up_helper_test.rb b/test/helpers/bubble_up_helper_test.rb new file mode 100644 index 0000000000..480e32a862 --- /dev/null +++ b/test/helpers/bubble_up_helper_test.rb @@ -0,0 +1,29 @@ +require "test_helper" + +class BubbleUpHelperTest < ActionView::TestCase + test "bubble_up_options_for returns nil when card has no bubble up" do + assert_nil bubble_up_options_for(cards(:logo)) + end + + test "bubble_up_options_for returns options when card has bubble up" do + card = cards(:postponed_idea) + + options = bubble_up_options_for(card) + assert_not_nil options + assert options[:isPostponed] + end + + test "slot_too_soon returns true when it's 17:00 or later for latertoday slot" do + travel_to Time.zone.parse("2026-01-26 17:00:00") do + assert_not slot_too_soon("tomorrow") + end + + travel_to Time.zone.parse("2026-01-26 16:00:00") do + assert_not slot_too_soon("latertoday") + end + + travel_to Time.zone.parse("2026-01-26 17:00:00") do + assert slot_too_soon("latertoday") + end + end +end diff --git a/test/models/card/bubble_up_test.rb b/test/models/card/bubble_up_test.rb new file mode 100644 index 0000000000..6835f61b6d --- /dev/null +++ b/test/models/card/bubble_up_test.rb @@ -0,0 +1,27 @@ +require "test_helper" + +class Card::BubbleUpTest < ActiveSupport::TestCase + test "due to resurface scope" do + bubbling_card = cards(:postponed_idea) + non_bubbling_card = cards(:logo) + + assert_not_includes Card::BubbleUp.due_to_resurface, bubbling_card.bubble_up + + bubbling_card.bubble_up.update(resurface_at: Time.now - 1.minute) + + assert_includes Card::BubbleUp.due_to_resurface, bubbling_card.bubble_up + assert_not_includes Card::BubbleUp.due_to_resurface, non_bubbling_card.bubble_up + end + + test "resurface all due" do + bubbling_card = cards(:postponed_idea) + bubbling_card.bubble_up.update(resurface_at: Time.now - 1.minute) + + assert_difference -> { Card.awaiting_triage.count } do + Card::BubbleUp.resurface_all_due + end + + assert_not bubbling_card.reload.postponed? + assert bubbling_card.reload.awaiting_triage? + end +end diff --git a/test/models/card/bubbles_up_test.rb b/test/models/card/bubbles_up_test.rb new file mode 100644 index 0000000000..b71c9e31c8 --- /dev/null +++ b/test/models/card/bubbles_up_test.rb @@ -0,0 +1,62 @@ +require "test_helper" + +class Card::BubblesUpTest < ActiveSupport::TestCase + setup do + Current.session = sessions(:david) + @bubbling, @non_bubbling = cards(:postponed_idea), cards(:text) + @bubble_up_time = 1.week.from_now + end + + test "check whether a card is bubbling" do + assert @bubbling.bubble_up? + assert @bubbling.bubbling? + assert_not @bubbling.bubbled? + + assert_not @non_bubbling.bubble_up? + assert_not @non_bubbling.bubbling? + assert_not @non_bubbling.bubbled? + end + + test "check whether a card has bubbled up" do + travel_to @bubbling.bubble_up.resurface_at + 1.minute do + assert @bubbling.bubble_up? + assert @bubbling.bubbled? + assert_not @bubbling.bubbling? + end + end + + test "bubble up and pop a card" do + assert_changes -> { @non_bubbling.reload.bubble_up? }, to: true do + @non_bubbling.bubble_up_at(@bubble_up_time) + end + + assert_changes -> { @bubbling.reload.bubble_up? }, to: false do + @bubbling.pop + end + end + + test "marking a card to bubble up postpones the card" do + assert_not @non_bubbling.postponed? + @non_bubbling.bubble_up_at(@bubble_up_time) + assert @non_bubbling.reload.postponed? + end + + test "change when a bubble up resurfaces" do + @bubbling.bubble_up_at(@bubble_up_time) + assert_in_delta @bubble_up_time, @bubbling.reload.bubble_up.resurface_at, 1.second + end + + test "bubbling up a card touches both the card and the board" do + board = @non_bubbling.board + + card_updated_at = @non_bubbling.updated_at + board_updated_at = board.updated_at + + travel 1.minute do + @non_bubbling.bubble_up_at(@bubble_up_time) + end + + assert @non_bubbling.reload.updated_at > card_updated_at + assert board.reload.updated_at > board_updated_at + end +end diff --git a/test/models/card/closeable_test.rb b/test/models/card/closeable_test.rb index d982be855c..1c59acc99c 100644 --- a/test/models/card/closeable_test.rb +++ b/test/models/card/closeable_test.rb @@ -59,4 +59,16 @@ class Card::CloseableTest < ActiveSupport::TestCase assert card.closed? assert_nil card.reload.not_now end + + test "close card with bubble up" do + card = cards(:logo) + card.bubble_up_at(1.hour.from_now) + + assert card.bubbling? + assert card.bubble_up.present? + + card.close + assert card.closed? + assert_nil card.reload.bubble_up + end end diff --git a/test/models/card/postponable_test.rb b/test/models/card/postponable_test.rb index 0bafd3ee85..b32b108c93 100644 --- a/test/models/card/postponable_test.rb +++ b/test/models/card/postponable_test.rb @@ -45,6 +45,17 @@ class Card::PostponableTest < ActiveSupport::TestCase assert card.events.last.action.card_auto_postponed? end + test "postponing pops bubble up if present" do + card = cards(:text) + card.bubble_up_at 1.hour.ago + + assert card.bubble_up? + card.postpone + + assert_not card.reload.bubble_up? + assert card.events.last.action.card_postponed? + end + test "scopes" do logo = cards(:logo) text = cards(:text) diff --git a/test/models/card/triageable_test.rb b/test/models/card/triageable_test.rb index ca88385e90..c004109010 100644 --- a/test/models/card/triageable_test.rb +++ b/test/models/card/triageable_test.rb @@ -32,6 +32,17 @@ class Card::TriageableTest < ActiveSupport::TestCase assert card.triaged? end + test "triaging a bubbled card pops it" do + card = cards(:buy_domain) + column = columns(:writebook_in_progress) + + card.bubble_up_at(1.hour.ago) + card.triage_into(column) + + assert card.reload.triaged? + assert_not card.bubble_up? + end + test "cannot triage into a column from a different board" do card = cards(:buy_domain) other_board_column = Column.create!( diff --git a/test/models/card_test.rb b/test/models/card_test.rb index 6ce17d9242..8e1fce5921 100644 --- a/test/models/card_test.rb +++ b/test/models/card_test.rb @@ -73,7 +73,7 @@ class CardTest < ActiveSupport::TestCase test "open" do assert_equal cards(:logo, :layout, :text, :buy_domain).to_set, accounts("37s").cards.open.to_set - assert_equal cards(:radio, :paycheck, :unfinished_thoughts).to_set, accounts("initech").cards.open.to_set + assert_equal cards(:radio, :paycheck, :unfinished_thoughts, :postponed_idea).to_set, accounts("initech").cards.open.to_set end test "card_unassigned" do diff --git a/test/models/time_slot_test.rb b/test/models/time_slot_test.rb new file mode 100644 index 0000000000..0535938e10 --- /dev/null +++ b/test/models/time_slot_test.rb @@ -0,0 +1,44 @@ +require "test_helper" + +class TimeSlotTest < ActiveSupport::TestCase + test "for latertoday" do + travel_to Time.zone.local(2023, 1, 1, 10, 0, 0) do + assert_equal Time.zone.local(2023, 1, 1, 18, 0, 0), TimeSlot.for("latertoday") + end + end + + test "for tomorrow" do + travel_to Time.zone.local(2023, 1, 1, 10, 0, 0) do + assert_equal Time.zone.local(2023, 1, 2, 8, 0, 0), TimeSlot.for("tomorrow") + end + end + + test "for thisweekend" do + travel_to Time.zone.local(2023, 1, 1, 10, 0, 0) do + assert_equal Time.zone.local(2023, 1, 7, 8, 0, 0), TimeSlot.for("thisweekend") + end + + travel_to Time.zone.local(2023, 1, 2, 10, 0, 0) do + assert_equal Time.zone.local(2023, 1, 7, 8, 0, 0), TimeSlot.for("thisweekend") + end + end + + test "for nextweek" do + travel_to Time.zone.local(2023, 1, 1, 10, 0, 0) do + assert_equal Time.zone.local(2023, 1, 2, 8, 0, 0), TimeSlot.for("nextweek") + end + + travel_to Time.zone.local(2023, 1, 2, 10, 0, 0) do + assert_equal Time.zone.local(2023, 1, 9, 8, 0, 0), TimeSlot.for("nextweek") + end + end + + test "for surprise" do + travel_to Time.zone.local(2023, 1, 1, 10, 0, 0) do + result = TimeSlot.for("surprise") + assert result >= Time.zone.local(2023, 1, 3, 8, 0, 0) + assert result <= Time.zone.local(2023, 1, 15, 8, 0, 0) + assert_equal 8, result.hour + end + end +end