diff --git a/.DS_Store b/.DS_Store
deleted file mode 100644
index 5008ddfcf..000000000
Binary files a/.DS_Store and /dev/null differ
diff --git a/app/assets/stylesheets/events.css b/app/assets/stylesheets/events.css
index 044a3c4c5..0096cd317 100644
--- a/app/assets/stylesheets/events.css
+++ b/app/assets/stylesheets/events.css
@@ -42,6 +42,10 @@
}
> * {
+ column-gap: var(--inline-space-double);
+ display: grid;
+ grid-auto-flow: row dense;
+ grid-template-columns: 1fr 1fr;
margin-inline: auto;
max-inline-size: 80ch;
}
@@ -49,6 +53,45 @@
a {
color: inherit;
}
+
+ h3 {
+ font-size: var(--text-large);
+ grid-column: span 2;
+ line-height: 1.3;
+ margin-block: 0.5em 0.25em;
+ text-align: center;
+ text-wrap: balance;
+
+ + p {
+ font-size: var(--text-medium);
+ grid-column: span 2;
+ margin-block: 0 1.5em;
+ text-align: center;
+ text-wrap: balance;
+ }
+ }
+
+ h4 {
+ font-size: var(--text-medium);
+ line-height: 1.3;
+ margin-block: 0.5em 0.25em;
+ grid-column: 1;
+
+ + p {
+ grid-column: 1;
+ margin-block: 0 1.5em;
+ }
+
+ &:nth-of-type(even) {
+ grid-column: 2;
+
+ + p {
+ grid-column: 2;
+ }
+ }
+ }
+
+ hr { display: none; }
}
.events__activity-generating-msg {
diff --git a/app/controllers/admin/prompt_sandboxes_controller.rb b/app/controllers/admin/prompt_sandboxes_controller.rb
index 32c37ae13..b4d6578ea 100644
--- a/app/controllers/admin/prompt_sandboxes_controller.rb
+++ b/app/controllers/admin/prompt_sandboxes_controller.rb
@@ -5,10 +5,10 @@ class Admin::PromptSandboxesController < AdminController
@llm_model = params[:llm_model] || Event::Summarizer::LLM_MODEL
if @prompt = cookies[:prompt].presence
- @activity_summary = build_activity_summary
+ @weekly_highlights = build_weekly_highlights
cookies.delete :prompt
else
- @activity_summary = @day_timeline.summary
+ @weekly_highlights = @day_timeline.weekly_highlights
@prompt = Event::Summarizer::PROMPT
end
end
@@ -21,9 +21,10 @@ class Admin::PromptSandboxesController < AdminController
end
private
- def build_activity_summary
- summarizer = Event::Summarizer.new(@day_timeline.events, prompt: @prompt, llm_model: @llm_model)
- content, cost_in_microcents = summarizer.summarize
- Event::ActivitySummary.new(content: content, cost_in_microcents: cost_in_microcents)
+ def build_weekly_highlights
+ period = PeriodHighlights::Period.new(Current.user.collections, starts_at: @day_timeline.day.beginning_of_week(:sunday), duration: 1.week)
+ summarizer = Event::Summarizer.new(period.events, prompt: @prompt, llm_model: @llm_model)
+ content = summarizer.summarized_content
+ PeriodHighlights.new(content: content, cost_in_microcents: summarizer.cost.in_microcents)
end
end
diff --git a/app/controllers/concerns/current_timezone.rb b/app/controllers/concerns/current_timezone.rb
index 9c36757c8..9c63ba679 100644
--- a/app/controllers/concerns/current_timezone.rb
+++ b/app/controllers/concerns/current_timezone.rb
@@ -3,6 +3,8 @@ module CurrentTimezone
included do
around_action :set_current_timezone
+
+ helper_method :timezone_from_cookie
end
private
@@ -12,6 +14,6 @@ module CurrentTimezone
def timezone_from_cookie
timezone = cookies[:timezone]
- timezone if timezone.present? && ActiveSupport::TimeZone[timezone]
+ ActiveSupport::TimeZone[timezone] if timezone.present?
end
end
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/controllers/my/timezones_controller.rb b/app/controllers/my/timezones_controller.rb
new file mode 100644
index 000000000..c70d7e80a
--- /dev/null
+++ b/app/controllers/my/timezones_controller.rb
@@ -0,0 +1,10 @@
+class My::TimezonesController < ApplicationController
+ def update
+ Current.user.settings.update!(timezone_name: timezone_param)
+ end
+
+ private
+ def timezone_param
+ params[:timezone_name]
+ end
+end
diff --git a/app/javascript/controllers/local_time_controller.js b/app/javascript/controllers/local_time_controller.js
index ee7dfd3a3..69693a107 100644
--- a/app/javascript/controllers/local_time_controller.js
+++ b/app/javascript/controllers/local_time_controller.js
@@ -1,6 +1,8 @@
import { Controller } from "@hotwired/stimulus"
import { differenceInDays } from "helpers/date_helpers"
+const DEFAULT_LOCALE = "en-US"
+
export default class extends Controller {
static targets = [ "time", "date", "datetime", "shortdate", "ago", "indays", "daysago", "agoorweekday", "timeordate" ]
static values = { refreshInterval: Number }
@@ -9,14 +11,14 @@ export default class extends Controller {
#timer
initialize() {
- this.timeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short" })
- this.dateFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "long" })
- this.shortdateFormatter = new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" })
- this.datetimeFormatter = new Intl.DateTimeFormat(undefined, { timeStyle: "short", dateStyle: "short" })
+ this.timeFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeStyle: "short" })
+ this.dateFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { dateStyle: "long" })
+ this.shortdateFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { month: "short", day: "numeric" })
+ this.datetimeFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeStyle: "short", dateStyle: "short" })
this.agoFormatter = new AgoFormatter()
this.daysagoFormatter = new DaysAgoFormatter()
- this.datewithweekdayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" })
- this.datewithweekdayFormatter = new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" })
+ this.datewithweekdayFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { weekday: "long", month: "long", day: "numeric" })
+ this.datewithweekdayFormatter = new Intl.DateTimeFormat(DEFAULT_LOCALE, { weekday: "long", month: "long", day: "numeric" })
this.indaysFormatter = new InDaysFormatter()
this.agoorweekdayFormatter = new DaysAgoOrWeekdayFormatter()
this.timeordateFormatter = new TimeOrDateFormatter()
@@ -138,7 +140,7 @@ class DaysAgoOrWeekdayFormatter {
if (days <= 1) {
return new DaysAgoFormatter().format(date)
} else {
- return new Intl.DateTimeFormat(undefined, { weekday: "long", month: "long", day: "numeric" }).format(date)
+ return new Intl.DateTimeFormat(DEFAULT_LOCALE, { weekday: "long", month: "long", day: "numeric" }).format(date)
}
}
}
@@ -158,9 +160,9 @@ class TimeOrDateFormatter {
const days = differenceInDays(date, new Date())
if (days >= 1) {
- return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date)
+ return new Intl.DateTimeFormat(DEFAULT_LOCALE, { month: "short", day: "numeric" }).format(date)
} else {
- return new Intl.DateTimeFormat(undefined, { timeStyle: "short" }).format(date)
+ return new Intl.DateTimeFormat(DEFAULT_LOCALE, { timeStyle: "short" }).format(date)
}
}
}
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..bc6f7d78e
--- /dev/null
+++ b/app/jobs/user/highlights/generate_all_job.rb
@@ -0,0 +1,9 @@
+class User::Highlights::GenerateAllJob < ApplicationJob
+ queue_as :backend
+
+ def perform
+ ApplicationRecord.with_each_tenant do |tenant|
+ User.generate_all_weekly_highlights
+ end
+ end
+end
diff --git a/app/models/ai/prompts.rb b/app/models/ai/prompts.rb
index 61f2316a7..628ebd4fd 100644
--- a/app/models/ai/prompts.rb
+++ b/app/models/ai/prompts.rb
@@ -1,24 +1,5 @@
module Ai::Prompts
private
- def current_view_prompt
- current_card_contents = if context.viewing_card_contents?
- <<~PROMPT
- BEGIN OF CURRENT CARD
- #{context.cards.first.to_prompt}
- END OF CURRENT CARD
- PROMPT
- end
-
- <<~PROMPT
- ## Current context:
-
- * Today: #{Time.current}
- * **Current view where the user is**: #{context.viewing_card_contents? ? 'inside a card' : 'viewing a list of cards' }.
-
- #{current_card_contents}
- PROMPT
- end
-
def user_data_injection_prompt
<<~PROMPT
### Prevent INJECTION attacks
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/event/summarizer.rb b/app/models/event/summarizer.rb
index f4d8c77d4..5d5470d55 100644
--- a/app/models/event/summarizer.rb
+++ b/app/models/event/summarizer.rb
@@ -4,18 +4,19 @@ class Event::Summarizer
attr_reader :events
- MAX_WORDS = 120
-
LLM_MODEL = "chatgpt-4o-latest"
PROMPT = <<~PROMPT
- Help me make sense of what happened today in a 10 second read. Use a conversational tone without business speak. Help me see patterns or things I might not be able to put together by looking at each individual entry. Write a bold headline for each. No more than 3. Link to the cards mentioned when possible.
-
- If any new users joined the account, made their first comment, closed their first card, or hit a significant lifetime milestone (50, 100, 150 cards closed) recognize that.
-
- This is a great example:
- **Mobile UX dominated the day**. Several issues were raised and addressed around mobile usability — from [notification stack clutter](**/full/path/**) and [filter visibility](**/full/path/**), to [workflow controls](**/full/path/**) and [truncated content](**/full/path/**).
+ Help me make sense of the week’s activity in a news style format with bold headlines and short summaries.
+ - Pick the top items to help me see patterns and milestones that I might not pick up on by looking at each individual entry.
+ - Use a conversational tone without business speak.
+ - Link to the issues naturally in context when possible, *do not* mention card numbers directly.
+ # Use this format:
+ - A single lead headline (### heading level 3) and blurb at the top that captures the overall theme of the week.
+ - Then 6 (or fewer) headlines (#### heading level 4) and blurbs for the most important stories.
+ - *Do not* add
elements.
+ - *Do not* insert a closing summary at the end.
Markdown link format: [anchor text](/full/path/).
- Preserve the path exactly as provided (including the leading "/").
- When linking to a Collection, paths should be in this format: (/[account id slug]/cards?collection_ids[]=x)
diff --git a/app/models/filter/params.rb b/app/models/filter/params.rb
index e7ee5a32b..3503ecef3 100644
--- a/app/models/filter/params.rb
+++ b/app/models/filter/params.rb
@@ -36,6 +36,12 @@ module Filter::Params
before_save { self.params_digest = self.class.digest_params(as_params) }
end
+ def used?(ignore_collections: false)
+ tags.any? || assignees.any? || creators.any? || closers.any? ||
+ stages.any? || terms.any? || card_ids&.any? || (!ignore_collections && collections.present?) ||
+ assignment_status.unassigned? || !indexed_by.all? || !sorted_by.latest?
+ end
+
# +as_params+ uses `resource#ids` instead of `#resource_ids`
# because the latter won't work on unpersisted filters.
def as_params
diff --git a/app/models/period_highlights.rb b/app/models/period_highlights.rb
new file mode 100644
index 000000000..72ebfa476
--- /dev/null
+++ b/app/models/period_highlights.rb
@@ -0,0 +1,37 @@
+class PeriodHighlights < ApplicationRecord
+ class << self
+ def create_or_find_for(collections, starts_at:, duration: 1.week)
+ self.for(collections, starts_at:, duration:) || create_for(collections, starts_at:, duration:)
+ end
+
+ def for(collections, starts_at:, duration: 1.week)
+ period = Period.new(collections, starts_at:, duration:)
+ find_by(key: period.key) if period.has_enough_activity?
+ end
+
+ private
+ def create_for(collections, starts_at:, duration: 1.week)
+ period = Period.new(collections, starts_at:, duration:)
+
+ if period.has_enough_activity?
+ summarizer = Event::Summarizer.new(period.events)
+ summarized_content = summarizer.summarized_content # outside of transaction as this can be slow
+
+ create_or_find_by!(key: period.key) do |record|
+ record.content = summarized_content
+ record.cost_in_microcents = summarizer.cost.in_microcents
+ end
+ end
+ end
+ end
+
+ def ends_at
+ starts_at + duration
+ 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/period_highlights/period.rb b/app/models/period_highlights/period.rb
new file mode 100644
index 000000000..a0fe2b2b2
--- /dev/null
+++ b/app/models/period_highlights/period.rb
@@ -0,0 +1,32 @@
+class PeriodHighlights::Period
+ MIN_EVENTS_TO_BE_INTERESTING = 7
+
+ attr_reader :collections, :starts_at, :duration
+
+ def initialize(collections, starts_at:, duration:)
+ @collections = collections
+ @starts_at = normalize_anchor_date(starts_at)
+ @duration = duration
+ end
+
+ def events
+ @events ||= Event.where(collection: collections).where(created_at: window)
+ end
+
+ def has_enough_activity?
+ events.count >= MIN_EVENTS_TO_BE_INTERESTING
+ end
+
+ def key
+ @key ||= Digest::SHA256.hexdigest(events.ids.sort.join("-"))
+ end
+
+ private
+ def window
+ starts_at..starts_at + duration
+ end
+
+ def normalize_anchor_date(date)
+ date.beginning_of_day
+ 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/configurable.rb b/app/models/user/configurable.rb
index cec3b6670..c9c5adf53 100644
--- a/app/models/user/configurable.rb
+++ b/app/models/user/configurable.rb
@@ -6,5 +6,11 @@ module User::Configurable
has_many :push_subscriptions, class_name: "Push::Subscription", dependent: :delete_all
after_create :create_settings, unless: :system?
+
+ delegate :timezone, to: :settings, allow_nil: true
+ end
+
+ def in_time_zone(&block)
+ Time.use_zone(timezone, &block)
end
end
diff --git a/app/models/user/day_timeline.rb b/app/models/user/day_timeline.rb
index 6050eaebe..754cdefc5 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
@@ -29,7 +29,27 @@ class User::DayTimeline
day.yesterday.beginning_of_day
end
+ def has_weekly_highlights?
+ !filter.used? && first_day_with_activity_this_week? && weekly_highlights.present?
+ end
+
+ def weekly_highlights
+ @weekly_highlights ||= user.weekly_highlights_for(week_starts_at - 1.week)
+ end
+
+ def week_starts_at
+ day.beginning_of_week(:sunday)
+ end
+
+ def week_ends_at
+ week_starts_at + 1.week
+ end
+
private
+ def first_day_with_activity_this_week?
+ day.monday? || (earliest_time.present? && earliest_time < day.beginning_of_week(:monday))
+ end
+
def filtered_events
@filtered_events ||= begin
events = Event.where(collection: collections)
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/filtering.rb b/app/models/user/filtering.rb
index 065e3bfb0..18ed68699 100644
--- a/app/models/user/filtering.rb
+++ b/app/models/user/filtering.rb
@@ -3,7 +3,7 @@ class User::Filtering
attr_reader :user, :filter, :expanded
- delegate :as_params, to: :filter
+ delegate :as_params, :any?, to: :filter
def initialize(user, filter, expanded: false)
@user, @filter, @expanded = user, filter, expanded
@@ -42,9 +42,7 @@ class User::Filtering
end
def any?
- filter.tags.any? || filter.assignees.any? || filter.creators.any? || filter.closers.any? ||
- filter.stages.any? || filter.terms.any? || filter.card_ids&.any? ||
- filter.assignment_status.unassigned? || !filter.indexed_by.all? || !filter.sorted_by.latest?
+ filter.used?(ignore_collections: true)
end
def show_indexed_by?
diff --git a/app/models/user/highlights.rb b/app/models/user/highlights.rb
new file mode 100644
index 000000000..52e74aea8
--- /dev/null
+++ b/app/models/user/highlights.rb
@@ -0,0 +1,32 @@
+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.
+ active.find_each(&:generate_weekly_highlights)
+ end
+ end
+
+ def generate_weekly_highlights(date = Time.current)
+ in_time_zone do
+ PeriodHighlights.create_or_find_for collections, starts_at: highlights_starts_at(date), duration: 1.week
+ end
+ end
+
+ def weekly_highlights_for(date)
+ in_time_zone do
+ PeriodHighlights.for(collections, starts_at: highlights_starts_at(date), duration: 1.week)
+ end
+ end
+
+ private
+ def highlights_starts_at(date = Time.current)
+ date.beginning_of_week(:sunday)
+ end
+end
diff --git a/app/models/user/settings.rb b/app/models/user/settings.rb
index 32dda805f..7a89d2f5a 100644
--- a/app/models/user/settings.rb
+++ b/app/models/user/settings.rb
@@ -23,6 +23,14 @@ class User::Settings < ApplicationRecord
!bundle_email_never? && !user.system?
end
+ def timezone
+ if timezone_name.present?
+ ActiveSupport::TimeZone[timezone_name] || default_timezone
+ else
+ default_timezone
+ end
+ end
+
private
def review_pending_bundles
if bundling_emails?
@@ -43,4 +51,8 @@ class User::Settings < ApplicationRecord
bundle.deliver_later
end
end
+
+ def default_timezone
+ ActiveSupport::TimeZone["UTC"]
+ end
end
diff --git a/app/views/admin/prompt_sandboxes/show.html.erb b/app/views/admin/prompt_sandboxes/show.html.erb
index be745ae53..73537537f 100644
--- a/app/views/admin/prompt_sandboxes/show.html.erb
+++ b/app/views/admin/prompt_sandboxes/show.html.erb
@@ -28,16 +28,7 @@
Summary
- <%= @activity_summary&.to_html %>
-
-
-
-
-
Summarized Contents
-
-
-<%= @day_timeline.summarizable_content %>
-
+ <%= @weekly_highlights&.to_html %>
diff --git a/app/views/events/_day.html.erb b/app/views/events/_day.html.erb
index b296404ee..29507f4c8 100644
--- a/app/views/events/_day.html.erb
+++ b/app/views/events/_day.html.erb
@@ -1,23 +1,14 @@
<% if day_timeline.has_activity? %>
-
-
- <%= render "events/day_timeline/summary", day_timeline: day_timeline if day_timeline.summarizable?%>
-
<%= render "events/day_timeline_column", event_type: "added", day_timeline: day_timeline %>
<%= render "events/day_timeline_column", event_type: "updated", day_timeline: day_timeline %>
<%= render "events/day_timeline_column", event_type: "closed", day_timeline: day_timeline %>
+ <%= render "events/weekly_highlights", day_timeline: day_timeline if day_timeline.has_weekly_highlights? %>
+
<%= render "events/empty_days", day_timeline: day_timeline %>
<% else %>
<%= render "events/empty_days", day_timeline: day_timeline %>
diff --git a/app/views/events/_weekly_highlights.html.erb b/app/views/events/_weekly_highlights.html.erb
new file mode 100644
index 000000000..1e1324e08
--- /dev/null
+++ b/app/views/events/_weekly_highlights.html.erb
@@ -0,0 +1,25 @@
+<% if day_timeline.has_weekly_highlights? %>
+
+
+
+
+ <%= day_timeline.weekly_highlights.to_html %>
+
+ <% 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 %>
+
+
+<% 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/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 9f62fc73b..4176e8c3f 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -9,6 +9,7 @@
<%= render "layouts/shared/flash" %>
+ <%= render "layouts/shared/time_zone" if Current.user %>
<%= yield %>
diff --git a/app/views/layouts/shared/_time_zone.html.erb b/app/views/layouts/shared/_time_zone.html.erb
new file mode 100644
index 000000000..5eb6e1f6d
--- /dev/null
+++ b/app/views/layouts/shared/_time_zone.html.erb
@@ -0,0 +1,5 @@
+<% if timezone_from_cookie.present? && timezone_from_cookie != Current.user.timezone %>
+ <%= auto_submit_form_with url: my_timezone_path, method: :put do %>
+ <%= hidden_field_tag :timezone_name, timezone_from_cookie.name %>
+ <% end %>
+<% 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..554545c2f 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
@@ -120,6 +119,7 @@ Rails.application.routes.draw do
namespace :my do
resources :pins
+ resource :timezone
end
namespace :prompts do
diff --git a/db/migrate/20250901114009_create_period_highlights.rb b/db/migrate/20250901114009_create_period_highlights.rb
new file mode 100644
index 000000000..1d7a9c491
--- /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/migrate/20250902093943_drop_event_activity_summaries.rb b/db/migrate/20250902093943_drop_event_activity_summaries.rb
new file mode 100644
index 000000000..d761d326a
--- /dev/null
+++ b/db/migrate/20250902093943_drop_event_activity_summaries.rb
@@ -0,0 +1,5 @@
+class DropEventActivitySummaries < ActiveRecord::Migration[8.1]
+ def change
+ drop_table :event_activity_summaries
+ end
+end
diff --git a/db/migrate/20250905093718_add_timezone_name_to_user_settings.rb b/db/migrate/20250905093718_add_timezone_name_to_user_settings.rb
new file mode 100644
index 000000000..c53da0916
--- /dev/null
+++ b/db/migrate/20250905093718_add_timezone_name_to_user_settings.rb
@@ -0,0 +1,5 @@
+class AddTimezoneNameToUserSettings < ActiveRecord::Migration[8.1]
+ def change
+ add_column :user_settings, :timezone_name, :string
+ end
+end
diff --git a/db/migrate/20250905101432_remove_dates_from_period_highlights.rb b/db/migrate/20250905101432_remove_dates_from_period_highlights.rb
new file mode 100644
index 000000000..42d3f3f6e
--- /dev/null
+++ b/db/migrate/20250905101432_remove_dates_from_period_highlights.rb
@@ -0,0 +1,6 @@
+class RemoveDatesFromPeriodHighlights < ActiveRecord::Migration[8.1]
+ def change
+ remove_column :period_highlights, :starts_at
+ remove_column :period_highlights, :duration
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index afd2ae05d..83da335e8 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_05_101432) do
create_table "accesses", force: :cascade do |t|
t.datetime "accessed_at"
t.integer "collection_id", null: false
@@ -248,15 +248,6 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_28_092106) do
t.index ["container_type", "container_id"], name: "index_entropy_configurations_on_container", unique: true
end
- create_table "event_activity_summaries", force: :cascade do |t|
- t.text "content", null: false
- t.bigint "cost_in_microcents", default: 0, null: false
- t.datetime "created_at", null: false
- t.string "key", null: false
- t.datetime "updated_at", null: false
- t.index ["key"], name: "index_event_activity_summaries_on_key", unique: true
- end
-
create_table "events", force: :cascade do |t|
t.string "action", null: false
t.integer "collection_id", null: false
@@ -333,6 +324,15 @@ 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.string "key", null: false
+ t.datetime "updated_at", null: false
+ t.index ["key"], 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
@@ -419,11 +419,13 @@ ActiveRecord::Schema[8.1].define(version: 2025_08_28_092106) do
t.datetime "created_at", null: false
t.string "title"
t.datetime "updated_at", null: false
+ t.index ["title"], name: "index_tags_on_account_id_and_title", unique: true
end
create_table "user_settings", force: :cascade do |t|
t.integer "bundle_email_frequency", default: 0, null: false
t.datetime "created_at", null: false
+ t.string "timezone_name"
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["user_id", "bundle_email_frequency"], name: "index_user_settings_on_user_id_and_bundle_email_frequency"
diff --git a/db/schema_cache.yml b/db/schema_cache.yml
index 77d7d292a..747b9a87d 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
+ - &36 !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: &32 !ruby/object:ActiveModel::Type::Boolean
precision:
scale:
limit:
- sql_type_metadata: &32 !ruby/object:ActiveRecord::ConnectionAdapters::SqlTypeMetadata
+ sql_type_metadata: &33 !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
+ - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: cost_in_microcents
cast_type: *11
@@ -782,31 +782,6 @@ columns:
- *5
- *6
- *9
- event_activity_summaries:
- - &33 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- auto_increment:
- name: content
- cast_type: *15
- sql_type_metadata: *16
- 'null': false
- default:
- default_function:
- collation:
- comment:
- - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
- auto_increment:
- name: cost_in_microcents
- cast_type: *11
- sql_type_metadata: *12
- 'null': false
- default: 0
- default_function:
- collation:
- comment:
- - *5
- - *6
- - *19
- - *9
events:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
@@ -900,7 +875,7 @@ columns:
comment:
filters_tags:
- *20
- - &34 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ - &35 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: tag_id
cast_type: *3
@@ -1016,6 +991,22 @@ columns:
- *29
- *9
- *18
+ period_highlights:
+ - &34 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: content
+ cast_type: *15
+ sql_type_metadata: *16
+ 'null': false
+ default:
+ default_function:
+ collation:
+ comment:
+ - *30
+ - *5
+ - *6
+ - *19
+ - *9
pins:
- *23
- *5
@@ -1056,7 +1047,7 @@ columns:
collation:
comment:
- *9
- - &30 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ - &31 !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: user_agent
cast_type: *7
@@ -1202,21 +1193,21 @@ columns:
collation:
comment:
- *9
- - *30
+ - *31
- *18
steps:
- *23
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: completed
- cast_type: *31
- sql_type_metadata: *32
+ cast_type: *32
+ sql_type_metadata: *33
'null': false
default: false
default_function:
collation:
comment:
- - *33
+ - *34
- *5
- *6
- *9
@@ -1224,12 +1215,12 @@ columns:
- *23
- *5
- *6
- - *34
+ - *35
- *9
tags:
- *5
- *6
- - *35
+ - *36
- *9
user_settings:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -1244,14 +1235,24 @@ columns:
comment:
- *5
- *6
+ - !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
+ auto_increment:
+ name: timezone_name
+ cast_type: *7
+ sql_type_metadata: *8
+ 'null': true
+ default:
+ default_function:
+ collation:
+ comment:
- *9
- *18
users:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: active
- cast_type: *31
- sql_type_metadata: *32
+ cast_type: *32
+ sql_type_metadata: *33
'null': false
default: true
default_function:
@@ -1310,8 +1311,8 @@ columns:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: watching
- cast_type: *31
- sql_type_metadata: *32
+ cast_type: *32
+ sql_type_metadata: *33
'null': false
default: true
default_function:
@@ -1374,7 +1375,6 @@ primary_keys:
conversations: id
creators_filters:
entropy_configurations: id
- event_activity_summaries: id
events: id
filters: id
filters_stages:
@@ -1382,6 +1382,7 @@ primary_keys:
mentions: id
notification_bundles: id
notifications: id
+ period_highlights: id
pins: id
push_subscriptions: id
reactions: id
@@ -1425,7 +1426,6 @@ data_sources:
conversations: true
creators_filters: true
entropy_configurations: true
- event_activity_summaries: true
events: true
filters: true
filters_stages: true
@@ -1433,6 +1433,7 @@ data_sources:
mentions: true
notification_bundles: true
notifications: true
+ period_highlights: true
pins: true
push_subscriptions: true
reactions: true
@@ -2211,23 +2212,6 @@ indexes:
nulls_not_distinct:
comment:
valid: true
- event_activity_summaries:
- - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
- table: event_activity_summaries
- name: index_event_activity_summaries_on_key
- unique: true
- columns:
- - key
- lengths: {}
- orders: {}
- opclasses: {}
- where:
- type:
- using:
- include:
- nulls_not_distinct:
- comment:
- valid: true
events:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: events
@@ -2551,6 +2535,23 @@ 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
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
pins:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: pins
@@ -2857,7 +2858,23 @@ indexes:
nulls_not_distinct:
comment:
valid: true
- tags: []
+ tags:
+ - !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
+ table: tags
+ name: index_tags_on_account_id_and_title
+ unique: true
+ columns:
+ - title
+ lengths: {}
+ orders: {}
+ opclasses: {}
+ where:
+ type:
+ using:
+ include:
+ nulls_not_distinct:
+ comment:
+ valid: true
user_settings:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: user_settings
@@ -2992,4 +3009,4 @@ indexes:
comment:
valid: true
workflows: []
-version: 20250828092106
+version: 20250905101432
diff --git a/script/migrations/backfill_weekly_highlights.rb b/script/migrations/backfill_weekly_highlights.rb
new file mode 100644
index 000000000..3653fe9f8
--- /dev/null
+++ b/script/migrations/backfill_weekly_highlights.rb
@@ -0,0 +1,15 @@
+#!/usr/bin/env ruby
+
+require_relative "../config/environment"
+
+WEEKS_TO_BACKFILL = 10
+
+ActiveRecord::Base.logger = Logger.new(File::NULL)
+
+ApplicationRecord.with_each_tenant do |tenant|
+ WEEKS_TO_BACKFILL.times do |index|
+ User.active.find_each do |user|
+ user.generate_weekly_highlights(Time.current - index.weeks)
+ end
+ end
+end
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/controllers/my/timezones_controller_test.rb b/test/controllers/my/timezones_controller_test.rb
new file mode 100644
index 000000000..98d45e304
--- /dev/null
+++ b/test/controllers/my/timezones_controller_test.rb
@@ -0,0 +1,15 @@
+require "test_helper"
+
+class My::TimezonesControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ sign_in_as :kevin
+ end
+
+ test "update" do
+ time_zone = ActiveSupport::TimeZone["America/New_York"]
+
+ assert_not_equal time_zone, users(:kevin).timezone
+ patch my_timezone_path, params: { timezone_name: "America/New_York" }
+ assert_equal time_zone, users(:kevin).reload.timezone
+ 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/filter_test.rb b/test/models/filter_test.rb
index 537cf384c..205d45725 100644
--- a/test/models/filter_test.rb
+++ b/test/models/filter_test.rb
@@ -168,4 +168,12 @@ class FilterTest < ActiveSupport::TestCase
filter = users(:david).filters.new closer_ids: [ users(:jz).id ]
assert_includes filter.cards, cards(:shipping)
end
+
+ test "check if a filter is used" do
+ assert users(:david).filters.new(creator_ids: [ users(:david).id ]).used?
+ assert_not users(:david).filters.new.used?
+
+ assert users(:david).filters.new(collection_ids: [ collections(:writebook).id ]).used?
+ assert_not users(:david).filters.new(collection_ids: [ collections(:writebook).id ]).used?(ignore_collections: true)
+ end
end
diff --git a/test/models/period_highlights_test.rb b/test/models/period_highlights_test.rb
new file mode 100644
index 000000000..5bcfca99a
--- /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_or_find_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_or_find_for(@user.collections, starts_at: 1.month.ago, duration: 2.months)
+
+ existing_period_highlights = assert_no_difference -> { PeriodHighlights.count } do
+ PeriodHighlights.create_or_find_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..ceceb51cc
--- /dev/null
+++ b/test/models/user/highlights_test.rb
@@ -0,0 +1,30 @@
+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
+ stub_const(PeriodHighlights::Period, :MIN_EVENTS_TO_BE_INTERESTING, 3) do
+ period_highlights = assert_difference -> { PeriodHighlights.count }, 1 do
+ @user.generate_weekly_highlights
+ end
+
+ assert_match /logo/i, period_highlights.to_html
+ end
+ 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..3941b8829 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/, "