diff --git a/app/controllers/events/activity_summaries_controller.rb b/app/controllers/events/activity_summaries_controller.rb
deleted file mode 100644
index 46e3c78f5..000000000
--- a/app/controllers/events/activity_summaries_controller.rb
+++ /dev/null
@@ -1,7 +0,0 @@
-class Events::ActivitySummariesController < ApplicationController
- include DayTimelinesScoped
-
- def create
- @day_timeline.summarize_later
- end
-end
diff --git a/app/jobs/user/day_timeline/summarize_job.rb b/app/jobs/user/day_timeline/summarize_job.rb
deleted file mode 100644
index f83347585..000000000
--- a/app/jobs/user/day_timeline/summarize_job.rb
+++ /dev/null
@@ -1,5 +0,0 @@
-class User::DayTimeline::SummarizeJob < ApplicationJob
- def perform(day_timeline)
- day_timeline.summarize
- end
-end
diff --git a/app/jobs/user/highlights/generate_all_job.rb b/app/jobs/user/highlights/generate_all_job.rb
new file mode 100644
index 000000000..82ce8d9a5
--- /dev/null
+++ b/app/jobs/user/highlights/generate_all_job.rb
@@ -0,0 +1,7 @@
+class User::Highlights::GenerateAllJob < ApplicationJob
+ queue_as :backend
+
+ def perform(user)
+ User.generate_all_weekly_highlights
+ end
+end
diff --git a/app/models/event/activity_summary.rb b/app/models/event/activity_summary.rb
deleted file mode 100644
index 4a68f2dc9..000000000
--- a/app/models/event/activity_summary.rb
+++ /dev/null
@@ -1,38 +0,0 @@
-class Event::ActivitySummary < ApplicationRecord
- validates :key, :content, presence: true
-
- after_create_commit :broadcast_activity_summarized
-
- class << self
- def create_for(events)
- key = key_for(events)
-
- # Outside to avoid holding the transaction during the LLM request
- summarizer = Event::Summarizer.new(events)
-
- create_or_find_by!(key: key) do |record|
- record.content = summarizer.summarized_content
- record.cost_in_microcents = summarizer.cost.in_microcents
- end
- end
-
- def for(events)
- find_by key: key_for(events)
- end
-
- def key_for(events)
- Digest::SHA256.hexdigest(events.ids.sort.join("-"))
- end
- end
-
- def to_html
- renderer = Redcarpet::Render::HTML.new
- markdowner = Redcarpet::Markdown.new(renderer, autolink: true, tables: true, fenced_code_blocks: true, strikethrough: true, superscript: true,)
- markdowner.render(content).html_safe
- end
-
- private
- def broadcast_activity_summarized
- broadcast_replace_later_to :activity_summaries, target: key, partial: "events/day_timeline/activity_summary", locals: { summary: self }
- end
-end
diff --git a/app/models/period_highlights.rb b/app/models/period_highlights.rb
new file mode 100644
index 000000000..357e5380d
--- /dev/null
+++ b/app/models/period_highlights.rb
@@ -0,0 +1,36 @@
+class PeriodHighlights < ApplicationRecord
+ class << self
+ def create_for(collections, starts_at:, duration: 1.week)
+ starts_at = normalize_anchor_date(starts_at)
+ key = key_for(collections)
+ events = Event.where(collection: collections).where(created_at: starts_at..starts_at + duration)
+
+ create_or_find_by!(key:, starts_at:, duration:) do |record|
+ summarizer = Event::Summarizer.new(events)
+ record.content = summarizer.summarized_content
+ record.cost_in_microcents = summarizer.cost.in_microcents
+ end
+ end
+
+ def for(collections, starts_at:, duration: 1.week)
+ starts_at = normalize_anchor_date(starts_at)
+ key = key_for(collections)
+ find_by(key:, starts_at:, duration:)
+ end
+
+ private
+ def key_for(collections)
+ Digest::SHA256.hexdigest(collections.ids.sort.join("-"))
+ end
+
+ def normalize_anchor_date(date)
+ date.utc.beginning_of_day
+ end
+ end
+
+ def to_html
+ renderer = Redcarpet::Render::HTML.new
+ markdowner = Redcarpet::Markdown.new(renderer, autolink: true, tables: true, fenced_code_blocks: true, strikethrough: true, superscript: true,)
+ markdowner.render(content).html_safe
+ end
+end
diff --git a/app/models/user.rb b/app/models/user.rb
index 39475882a..64a5d1769 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -1,6 +1,6 @@
class User < ApplicationRecord
- include Accessor, AiQuota, Assignee, Attachable, Configurable, Conversational, Mentionable, Named,
- Notifiable, Role, Searcher, SignalUser, Staff, Transferable
+ include Accessor, AiQuota, Assignee, Attachable, Configurable, Conversational, Highlights,
+ Mentionable, Named, Notifiable, Role, Searcher, SignalUser, Staff, Transferable
include Timelined # Depends on Accessor
has_one_attached :avatar
diff --git a/app/models/user/day_timeline.rb b/app/models/user/day_timeline.rb
index 6050eaebe..71b520c6d 100644
--- a/app/models/user/day_timeline.rb
+++ b/app/models/user/day_timeline.rb
@@ -1,5 +1,5 @@
class User::DayTimeline
- include Serializable, Summarizable
+ include Serializable
attr_reader :user, :day, :filter
diff --git a/app/models/user/day_timeline/summarizable.rb b/app/models/user/day_timeline/summarizable.rb
deleted file mode 100644
index ecbc30a6a..000000000
--- a/app/models/user/day_timeline/summarizable.rb
+++ /dev/null
@@ -1,36 +0,0 @@
-module User::DayTimeline::Summarizable
- extend ActiveSupport::Concern
-
- def summarized?
- summary.present?
- end
-
- def summary
- @summary ||= Event::ActivitySummary.for(events)
- end
-
- def summarizable?
- day.past? || summarizable_today?
- end
-
- def summarize
- Event::ActivitySummary.create_for(events)
- end
-
- def summary_key
- Event::ActivitySummary.key_for(events)
- end
-
- def summarizable_content
- Event::Summarizer.new(events).summarizable_content
- end
-
- def summarize_later
- User::DayTimeline::SummarizeJob.perform_later(self)
- end
-
- private
- def summarizable_today?
- day.today? && events.count >= 10
- end
-end
diff --git a/app/models/user/highlights.rb b/app/models/user/highlights.rb
new file mode 100644
index 000000000..d40abafdd
--- /dev/null
+++ b/app/models/user/highlights.rb
@@ -0,0 +1,28 @@
+module User::Highlights
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def generate_all_weekly_highlights_later
+ User::Highlights::GenerateAllJob.perform_later
+ end
+
+ def generate_all_weekly_highlights
+ # We're not interested in parallelizing individual generation. Better for AI quota limits and, also,
+ # most summaries will be reused for users accessing the same collections.
+ find_each(&:generate_weekly_highlights)
+ end
+ end
+
+ def generate_weekly_highlights
+ PeriodHighlights.create_for collections, starts_at: current_highlights_starts_at, duration: 1.week
+ end
+
+ def current_weekly_highlights
+ PeriodHighlights.for collections, starts_at: current_highlights_starts_at, duration: 1.week
+ end
+
+ private
+ def current_highlights_starts_at
+ Time.current.utc.beginning_of_week(:sunday)
+ end
+end
diff --git a/app/models/user/role.rb b/app/models/user/role.rb
index 4a90410b5..4bc09b518 100644
--- a/app/models/user/role.rb
+++ b/app/models/user/role.rb
@@ -3,6 +3,7 @@ module User::Role
included do
enum :role, %i[ admin member system ].index_by(&:itself), scopes: false
+ scope :regular, -> { where(role: %i[ admin member ]) }
scope :member, -> { where(role: :member) }
scope :active, -> { where(active: true, role: %i[ admin member ]) }
diff --git a/app/views/events/_day.html.erb b/app/views/events/_day.html.erb
index b296404ee..6fbcc8b50 100644
--- a/app/views/events/_day.html.erb
+++ b/app/views/events/_day.html.erb
@@ -10,7 +10,7 @@
- <%= render "events/day_timeline/summary", day_timeline: day_timeline if day_timeline.summarizable?%>
+ <%= render "events/weekly_summary" %>
<%= render "events/day_timeline_column", event_type: "added", day_timeline: day_timeline %>
diff --git a/app/views/events/_weekly_summary.html.erb b/app/views/events/_weekly_summary.html.erb
new file mode 100644
index 000000000..646669e27
--- /dev/null
+++ b/app/views/events/_weekly_summary.html.erb
@@ -0,0 +1,5 @@
+<% if weekly_highlights = Current.user.current_weekly_highlights %>
+
+ <%= weekly_highlights.to_html %>
+
+<% end %>
diff --git a/app/views/events/day_timeline/_activity_summary.html.erb b/app/views/events/day_timeline/_activity_summary.html.erb
deleted file mode 100644
index d4ab7c977..000000000
--- a/app/views/events/day_timeline/_activity_summary.html.erb
+++ /dev/null
@@ -1,3 +0,0 @@
-
- <%= summary.to_html %>
-
diff --git a/app/views/events/day_timeline/_summary.html.erb b/app/views/events/day_timeline/_summary.html.erb
deleted file mode 100644
index 58979c873..000000000
--- a/app/views/events/day_timeline/_summary.html.erb
+++ /dev/null
@@ -1,17 +0,0 @@
-
- <% if day_timeline.summarized? %>
- <%= render "events/day_timeline/activity_summary", summary: day_timeline.summary %>
-
- <% if Current.user.staff? %>
- <%= link_to admin_prompt_sandbox_path(day: day_timeline.day), class: "btn txt-xx-small events__activity-prompt-edit" do %>
- <%= icon_tag "bolt" %>
-
Prompt sandbox
- <% end %>
- <% end %>
- <% else %>
-
- <%= auto_submit_form_with url: events_activity_summaries_path(day: day_timeline.day, **day_timeline.filter.as_params), method: :post %>
- Writing summary…
-
- <% end %>
-
diff --git a/config/recurring.yml b/config/recurring.yml
index 66188ec7a..8e365b85f 100644
--- a/config/recurring.yml
+++ b/config/recurring.yml
@@ -20,6 +20,9 @@ production: &production
deliver_bundled_notifications:
command: "Notification::Bundle.deliver_all_later"
schedule: every 30 minutes
+ generate_weekly_highlights:
+ command: "User.generate_all_weekly_highlights_later"
+ schedule: 0 12 * * sun # every Sunday at noon
beta: *production
staging: *production
diff --git a/config/routes.rb b/config/routes.rb
index 4b6b4d073..348e3254f 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -75,7 +75,6 @@ Rails.application.routes.draw do
resources :events, only: :index
namespace :events do
- resources :activity_summaries
resources :days
end
diff --git a/db/migrate/20250901114009_create_period_highlights.rb b/db/migrate/20250901114009_create_period_highlights.rb
new file mode 100644
index 000000000..e082cdf6a
--- /dev/null
+++ b/db/migrate/20250901114009_create_period_highlights.rb
@@ -0,0 +1,15 @@
+class CreatePeriodHighlights < ActiveRecord::Migration[8.1]
+ def change
+ create_table :period_highlights do |t|
+ t.datetime :starts_at, null: false
+ t.bigint :duration, null: false, default: 1.week.to_i
+ t.string :key, null: false
+ t.text :content, null: false
+ t.bigint :cost_in_microcents
+
+ t.index %i[ key starts_at duration ] , unique: true
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index afd2ae05d..e3598e0c5 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_08_28_092106) do
+ActiveRecord::Schema[8.1].define(version: 2025_09_01_114009) do
create_table "accesses", force: :cascade do |t|
t.datetime "accessed_at"
t.integer "collection_id", null: false
@@ -333,6 +333,17 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_28_092106) do
t.index ["user_id"], name: "index_notifications_on_user_id"
end
+ create_table "period_highlights", force: :cascade do |t|
+ t.text "content", null: false
+ t.bigint "cost_in_microcents"
+ t.datetime "created_at", null: false
+ t.bigint "duration", default: 604800, null: false
+ t.string "key", null: false
+ t.datetime "starts_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["key", "starts_at", "duration"], name: "index_period_highlights_on_key_and_starts_at_and_duration", unique: true
+ end
+
create_table "pins", force: :cascade do |t|
t.integer "card_id", null: false
t.datetime "created_at", null: false
diff --git a/db/schema_cache.yml b/db/schema_cache.yml
index 77d7d292a..35e38d49e 100644
--- a/db/schema_cache.yml
+++ b/db/schema_cache.yml
@@ -496,7 +496,7 @@ columns:
default_function:
collation:
comment:
- - &35 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ - &37 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: title
cast_type: *7
@@ -577,11 +577,11 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: all_access
- cast_type: &31 !ruby/object:ActiveModel::Type::Boolean
+ cast_type: &34 !ruby/object:ActiveModel::Type::Boolean
precision:
scale:
limit:
- sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
+ sql_type_metadata: &35 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
sql_type: boolean
type: :boolean
limit:
@@ -637,7 +637,7 @@ columns:
default_function:
collation:
comment:
- - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ - &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: cost_in_microcents
cast_type: *11
@@ -783,7 +783,7 @@ columns:
- *6
- *9
event_activity_summaries:
- - &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: content
cast_type: *15
@@ -900,7 +900,7 @@ columns:
comment:
filters_tags:
- *20
- - &34 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ - &36 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: tag_id
cast_type: *3
@@ -967,7 +967,7 @@ columns:
collation:
comment:
- *6
- - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ - &32 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: starts_at
cast_type: *1
@@ -1016,6 +1016,24 @@ columns:
- *29
- *9
- *18
+ period_highlights:
+ - *30
+ - *31
+ - *5
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: duration
+ cast_type: *11
+ sql_type_metadata: *12
+ 'null': false
+ default: 604800
+ default_function:
+ collation:
+ comment:
+ - *6
+ - *19
+ - *32
+ - *9
pins:
- *23
- *5
@@ -1056,7 +1074,7 @@ columns:
collation:
comment:
- *9
- - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ - &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: user_agent
cast_type: *7
@@ -1202,21 +1220,21 @@ columns:
collation:
comment:
- *9
- - *30
+ - *33
- *18
steps:
- *23
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: completed
- cast_type: *31
- sql_type_metadata: *32
+ cast_type: *34
+ sql_type_metadata: *35
'null': false
default: false
default_function:
collation:
comment:
- - *33
+ - *30
- *5
- *6
- *9
@@ -1224,12 +1242,12 @@ columns:
- *23
- *5
- *6
- - *34
+ - *36
- *9
tags:
- *5
- *6
- - *35
+ - *37
- *9
user_settings:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -1250,8 +1268,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: active
- cast_type: *31
- sql_type_metadata: *32
+ cast_type: *34
+ sql_type_metadata: *35
'null': false
default: true
default_function:
@@ -1310,8 +1328,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: watching
- cast_type: *31
- sql_type_metadata: *32
+ cast_type: *34
+ sql_type_metadata: *35
'null': false
default: true
default_function:
@@ -1382,6 +1400,7 @@ primary_keys:
mentions: id
notification_bundles: id
notifications: id
+ period_highlights: id
pins: id
push_subscriptions: id
reactions: id
@@ -1433,6 +1452,7 @@ data_sources:
mentions: true
notification_bundles: true
notifications: true
+ period_highlights: true
pins: true
push_subscriptions: true
reactions: true
@@ -2551,6 +2571,25 @@ indexes:
nulls_not_distinct:
comment:
valid: true
+ period_highlights:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: period_highlights
+ name: index_period_highlights_on_key_and_starts_at_and_duration
+ unique: true
+ columns:
+ - key
+ - starts_at
+ - duration
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
pins:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: pins
@@ -2992,4 +3031,4 @@ indexes:
comment:
valid: true
workflows: []
-version: 20250828092106
+version: 20250901114009
diff --git a/test/controllers/events/activity_summaries_controller_test.rb b/test/controllers/events/activity_summaries_controller_test.rb
deleted file mode 100644
index 264946bf8..000000000
--- a/test/controllers/events/activity_summaries_controller_test.rb
+++ /dev/null
@@ -1,19 +0,0 @@
-require "test_helper"
-
-class Events::ActivitySummariesControllerTest < ActionDispatch::IntegrationTest
- include VcrTestHelper
-
- setup do
- sign_in_as :kevin
-
- freeze_timestamps
- end
-
- test "create" do
- assert_difference -> { Event::ActivitySummary.count }, +1 do
- perform_enqueued_jobs only: User::DayTimeline::SummarizeJob do
- post events_activity_summaries_path(day: Date.current)
- end
- end
- end
-end
diff --git a/test/models/event/activity_summary_test.rb b/test/models/event/activity_summary_test.rb
deleted file mode 100644
index eb4326582..000000000
--- a/test/models/event/activity_summary_test.rb
+++ /dev/null
@@ -1,33 +0,0 @@
-require "test_helper"
-
-class Event::ActivitySummaryTest < ActiveSupport::TestCase
- include VcrTestHelper
-
- setup do
- @events = Event.limit(3)
- freeze_timestamps
- end
-
- test "create summaries only once for a given set of events" do
- summary = assert_difference -> { Event::ActivitySummary.count }, +1 do
- Event::ActivitySummary.create_for(@events)
- end
-
- assert_no_difference -> { Event::ActivitySummary.count } do
- assert_equal summary, Event::ActivitySummary.create_for(@events)
- assert_equal summary, Event::ActivitySummary.create_for(@events.order("action desc").where(id: @events.ids)) # order does not matter
- end
- end
-
- test "fetching a existing summary" do
- assert_nil Event::ActivitySummary.for(@events)
-
- summary = Event::ActivitySummary.create_for(@events)
- assert_equal summary, Event::ActivitySummary.for(@events)
- end
-
- test "getting an HTML summary for a set of events" do
- summary = Event::ActivitySummary.create_for(@events)
- assert_match /layout/i, summary.to_html
- end
-end
diff --git a/test/models/period_highlights_test.rb b/test/models/period_highlights_test.rb
new file mode 100644
index 000000000..b1c942a91
--- /dev/null
+++ b/test/models/period_highlights_test.rb
@@ -0,0 +1,27 @@
+require "test_helper"
+
+class PeriodHighlightTest < ActiveSupport::TestCase
+ include VcrTestHelper
+
+ setup do
+ @user = users(:david)
+ end
+
+ test "generate period highlights" do
+ period_highlights = assert_difference -> { PeriodHighlights.count }, 1 do
+ PeriodHighlights.create_for(@user.collections, starts_at: 1.month.ago, duration: 2.months)
+ end
+
+ assert_match /logo/i, period_highlights.to_html
+ end
+
+ test "don't generate highlights for existing periods" do
+ new_period_highlights = PeriodHighlights.create_for(@user.collections, starts_at: 1.month.ago, duration: 2.months)
+
+ existing_period_highlights = assert_no_difference -> { PeriodHighlights.count } do
+ PeriodHighlights.create_for(@user.collections, starts_at: 1.month.ago, duration: 2.months)
+ end
+
+ assert_equal new_period_highlights, existing_period_highlights
+ end
+end
diff --git a/test/models/user/day_timeline/summarizable_test.rb b/test/models/user/day_timeline/summarizable_test.rb
deleted file mode 100644
index 760e2d353..000000000
--- a/test/models/user/day_timeline/summarizable_test.rb
+++ /dev/null
@@ -1,58 +0,0 @@
-require "test_helper"
-
-class User::DayTimeline::SummarizableTest < ActiveSupport::TestCase
- include VcrTestHelper
-
- setup do
- @user = users(:david)
-
- freeze_timestamps
-
- Current.session = sessions(:david)
- @day = events(:logo_published).reload.created_at
- @day_timeline = @user.timeline_for(@day, filter: @user.filters.new)
- end
-
- test "summarize" do
- assert_not @day_timeline.summarized?
- assert @day_timeline.events.any?
-
- summary = assert_difference -> { Event::ActivitySummary.count }, +1 do
- @day_timeline.summarize
- end
-
- assert @day_timeline.summarized?
-
- assert_equal summary, @day_timeline.summary
-
- assert_no_difference -> { Event::ActivitySummary.count } do
- @day_timeline.summarize
- end
- end
-
- test "past events are summarizable" do
- unfreeze_time
-
- # Not summarizable in the future
- travel_to @day - 2.weeks
- assert_not @day_timeline.summarizable?
-
- # Summarizable in the past
- travel_to @day + 1.day
- assert @day_timeline.summarizable?
- end
-
- test "today events are summarizable" do
- unfreeze_time
-
- travel_to @day
- assert_not @day_timeline.summarizable?
-
- 10.times.each do |index|
- cards(:logo).update! title: "Title change #{index} to track event"
- end
-
- # Summarizable in the past
- assert @day_timeline.summarizable?
- end
-end
diff --git a/test/models/user/highlights_test.rb b/test/models/user/highlights_test.rb
new file mode 100644
index 000000000..e28ad3078
--- /dev/null
+++ b/test/models/user/highlights_test.rb
@@ -0,0 +1,29 @@
+require "test_helper"
+
+class User::HighlightsTest < ActiveSupport::TestCase
+ include VcrTestHelper
+
+ setup do
+ @user = users(:david)
+ travel_to 1.week.ago + 2.days
+ end
+
+ test "generate weekly highlights" do
+ period_highlights = assert_difference -> { PeriodHighlights.count }, 1 do
+ @user.generate_weekly_highlights
+ end
+
+ assert_equal Time.current.beginning_of_week(:sunday).utc, period_highlights.starts_at
+ assert_match /logo/i, period_highlights.to_html
+ end
+
+ test "don't generate highlights for existing periods" do
+ new_period_highlights = @user.generate_weekly_highlights
+
+ existing_period_highlights = assert_no_difference -> { PeriodHighlights.count } do
+ @user.generate_weekly_highlights
+ end
+
+ assert_equal new_period_highlights, existing_period_highlights
+ end
+end
diff --git a/test/test_helper.rb b/test/test_helper.rb
index d0c6b8710..4d4906d97 100644
--- a/test/test_helper.rb
+++ b/test/test_helper.rb
@@ -20,6 +20,23 @@ VCR.configure do |config|
config.default_cassette_options = {
match_requests_on: [ :method, :uri, :body ]
}
+
+ # Ignore timestamps in request bodies
+ config.before_record do |i|
+ if i.request&.body
+ i.request.body.gsub!(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/, "