diff --git a/app/controllers/bubbles/pops_controller.rb b/app/controllers/bubbles/pops_controller.rb
index c19784433..7abedb285 100644
--- a/app/controllers/bubbles/pops_controller.rb
+++ b/app/controllers/bubbles/pops_controller.rb
@@ -2,7 +2,7 @@ class Bubbles::PopsController < ApplicationController
include BubbleScoped, BucketScoped
def create
- @bubble.pop!
+ @bubble.pop!(user: Current.user)
redirect_to @bubble
end
diff --git a/app/javascript/controllers/local_time_controller.js b/app/javascript/controllers/local_time_controller.js
index 8151d6e40..bd02d8598 100644
--- a/app/javascript/controllers/local_time_controller.js
+++ b/app/javascript/controllers/local_time_controller.js
@@ -1,7 +1,7 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
- static targets = ["time", "date", "datetime", "shortdate", "ago"]
+ static targets = [ "time", "date", "datetime", "shortdate", "ago", "indays" ]
#timer
@@ -11,6 +11,7 @@ export default class extends Controller {
this.shortDateFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" })
this.dateTimeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short", dateStyle: "short" })
this.agoFormatter = new AgoFormatter()
+ this.indaysFormatter = new InDaysFormatter()
}
connect() {
@@ -41,6 +42,10 @@ export default class extends Controller {
this.#formatTime(this.agoFormatter, target)
}
+ indaysTargetConnected(target) {
+ this.#formatTime(this.indaysFormatter, target)
+ }
+
#refreshRelativeTimes() {
this.agoTargets.forEach(target => {
this.#formatTime(this.agoFormatter, target)
@@ -81,3 +86,24 @@ class AgoFormatter {
return `${quantity} ${word}${suffix} ago`
}
}
+
+class InDaysFormatter {
+ format(dt) {
+ const target = this.#beginningOfDay(dt)
+ const today = this.#beginningOfDay(new Date())
+ const days = Math.round((target - today) / (1000 * 60 * 60 * 24))
+
+ if (days <= 0) {
+ return "today"
+ }
+ if (days === 1) {
+ return "tomorrow"
+ }
+
+ return `in ${Math.round(days)} days`
+ }
+
+ #beginningOfDay(dt) {
+ return new Date(dt.getFullYear(), dt.getMonth(), dt.getDate())
+ }
+}
diff --git a/app/models/bubble/poppable.rb b/app/models/bubble/poppable.rb
index c60474d89..afd1803fa 100644
--- a/app/models/bubble/poppable.rb
+++ b/app/models/bubble/poppable.rb
@@ -1,21 +1,45 @@
module Bubble::Poppable
extend ActiveSupport::Concern
+ AUTO_POP_AFTER = 30.days
+
included do
has_one :pop, dependent: :destroy
scope :popped, -> { joins(:pop) }
scope :active, -> { where.missing(:pop) }
+
+ after_create -> { update_auto_pop_at(created_at) }
+ end
+
+ class_methods do
+ def auto_pop_all_due
+ active.where(auto_pop_at: ..Time.current).find_each do |bubble|
+ bubble.pop!(user: bubble.bucket.account.users.system)
+ end
+ end
+ end
+
+ def update_auto_pop_at(timestamp)
+ update!(auto_pop_at: timestamp + AUTO_POP_AFTER)
end
def popped?
pop.present?
end
+ def popped_by
+ pop&.user
+ end
+
+ def popped_at
+ pop&.created_at
+ end
+
def pop!(user: Current.user)
unless popped?
create_pop!(user: user)
- track_event :popped
+ track_event :popped, creator: user
end
end
diff --git a/app/models/event.rb b/app/models/event.rb
index 6b29885bc..b8a5f5bbd 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -13,6 +13,8 @@ class Event < ApplicationRecord
scope :non_boosts, -> { where.not action: :boosted }
scope :boosts, -> { where action: :boosted }
+ after_create -> { bubble.update_auto_pop_at(created_at) }
+
def generate_notifications
Notifier.for(self)&.generate
end
diff --git a/app/views/bubbles/_pop_toggle.html.erb b/app/views/bubbles/_pop_toggle.html.erb
index ff430eae8..02b92c49f 100644
--- a/app/views/bubbles/_pop_toggle.html.erb
+++ b/app/views/bubbles/_pop_toggle.html.erb
@@ -1,19 +1,16 @@
<% if bubble.popped? %>
- <% event = bubble.events.where(action: "popped").last %>
- Popped by <%= "#{ event.creator.name } on #{ event.created_at.to_date.strftime('%B %-d') }" if event %>.
+ Popped by <%= bubble.popped_by.name %> on <%= local_datetime_tag(bubble.popped_at, style: :shortdate) %>.
<%= button_to bucket_bubble_pop_path(bubble.bucket, bubble), method: :delete, class: "btn btn--plain borderless fill-transparent" do %>
Un-pop
<% end %>
<% else %>
- <% event = bubble.events.last %>
<%= button_to bucket_bubble_pop_path(bubble.bucket, bubble), class: "btn borderless" do %>
<%= image_tag "pop.svg", aria: { hidden: true }, size: 24, class: "colorize--white" %>
Pop
<% end %>
- <% time_until_pop = (event&.created_at || bubble.created_at) + 30.days %>
- Auto-pops <%= Time.current >= time_until_pop ? "today" : "in #{distance_of_time_in_words(Time.current, time_until_pop)}" %>.
+ Auto-pops <%= local_datetime_tag(bubble.auto_pop_at, style: :indays) %>.
<% end %>
diff --git a/db/migrate/20250212103251_add_auto_pop_at_to_bubbles.rb b/db/migrate/20250212103251_add_auto_pop_at_to_bubbles.rb
index d2185265f..39f59c3db 100644
--- a/db/migrate/20250212103251_add_auto_pop_at_to_bubbles.rb
+++ b/db/migrate/20250212103251_add_auto_pop_at_to_bubbles.rb
@@ -3,5 +3,18 @@ class AddAutoPopAtToBubbles < ActiveRecord::Migration[8.1]
change_table :bubbles do |t|
t.datetime :auto_pop_at, index: true
end
+
+ execute "
+ update bubbles
+ set auto_pop_at = datetime(activity.last_active_at, '+30 days')
+ from (
+ select bubbles.id as bubble_id, coalesce(max(events.created_at), bubbles.created_at) as last_active_at
+ from bubbles
+ left join events on bubbles.id = events.bubble_id group by bubbles.id
+ ) as activity
+ where bubbles.id = activity.bubble_id
+ "
+
+ change_column_null :bubbles, :auto_pop_at, false
end
end
diff --git a/db/schema.rb b/db/schema.rb
index c8a0a15d9..16dd73512 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2025_02_12_094743) do
+ActiveRecord::Schema[8.1].define(version: 2025_02_12_103251) do
create_table "accesses", force: :cascade do |t|
t.integer "bucket_id", null: false
t.integer "user_id", null: false
@@ -107,6 +107,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_02_12_094743) do
t.integer "comments_count", default: 0, null: false
t.integer "activity_score", default: 0, null: false
t.text "status", default: "creating", null: false
+ t.datetime "auto_pop_at", null: false
+ t.index ["auto_pop_at"], name: "index_bubbles_on_auto_pop_at"
t.index ["bucket_id"], name: "index_bubbles_on_bucket_id"
t.index ["stage_id"], name: "index_bubbles_on_stage_id"
end
diff --git a/test/fixtures/bubbles.yml b/test/fixtures/bubbles.yml
index 8bbde128f..e135db675 100644
--- a/test/fixtures/bubbles.yml
+++ b/test/fixtures/bubbles.yml
@@ -9,6 +9,7 @@ logo:
comments_count: 2
activity_score: 7
status: published
+ auto_pop_at: <%= 1.week.from_now %>
layout:
bucket: writebook
@@ -19,6 +20,7 @@ layout:
comments_count: 1
activity_score: 1
status: published
+ auto_pop_at: <%= 1.week.from_now %>
text:
bucket: writebook
@@ -27,6 +29,7 @@ text:
color: "#3B4B59"
created_at: <%= 1.week.ago %>
status: published
+ auto_pop_at: <%= 1.week.from_now %>
shipping:
bucket: writebook
@@ -35,3 +38,4 @@ shipping:
color: "#ED8008"
created_at: <%= 1.week.ago %>
status: published
+ auto_pop_at: <%= 1.week.from_now %>
diff --git a/test/models/bubble/poppable_test.rb b/test/models/bubble/poppable_test.rb
index cff8d1a38..f205ba790 100644
--- a/test/models/bubble/poppable_test.rb
+++ b/test/models/bubble/poppable_test.rb
@@ -9,10 +9,9 @@ class Bubble::PoppableTest < ActiveSupport::TestCase
test "popping" do
assert_not bubbles(:logo).popped?
- with_current_user(:kevin) do
- bubbles(:logo).pop!
- end
+ bubbles(:logo).pop!(user: users(:kevin))
assert bubbles(:logo).popped?
+ assert_equal users(:kevin), bubbles(:logo).popped_by
end
end