Merge pull request #1007 from basecamp/weekly-highlights

Weekly highlights
This commit is contained in:
Jorge Manrubia
2025-09-05 17:03:38 +02:00
committed by GitHub
52 changed files with 7676 additions and 403 deletions
Vendored
BIN
View File
Binary file not shown.
+43
View File
@@ -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 {
@@ -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
+3 -1
View File
@@ -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
@@ -1,7 +0,0 @@
class Events::ActivitySummariesController < ApplicationController
include DayTimelinesScoped
def create
@day_timeline.summarize_later
end
end
@@ -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
@@ -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)
}
}
}
@@ -1,5 +0,0 @@
class User::DayTimeline::SummarizeJob < ApplicationJob
def perform(day_timeline)
day_timeline.summarize
end
end
@@ -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
-19
View File
@@ -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
-38
View File
@@ -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
+9 -8
View File
@@ -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 weeks 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 <hr> 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)
+6
View File
@@ -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
+37
View File
@@ -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
+32
View File
@@ -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
+2 -2
View File
@@ -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
+6
View File
@@ -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
+21 -1
View File
@@ -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)
@@ -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
+2 -4
View File
@@ -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?
+32
View File
@@ -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
+12
View File
@@ -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
+1 -10
View File
@@ -28,16 +28,7 @@
<div class="margin-block fill-shade txt-ink border border-radius pad">
<h2 class="margin-block">Summary</h2>
<div class="summary-content">
<%= @activity_summary&.to_html %>
</div>
</div>
<div class="margin-block fill-shade txt-ink border border-radius pad">
<h2 class="margin-block">Summarized Contents</h2>
<div class="summary-content">
<pre>
<%= @day_timeline.summarizable_content %>
</pre>
<%= @weekly_highlights&.to_html %>
</div>
</div>
</div>
+2 -11
View File
@@ -1,23 +1,14 @@
<section class="events__day" data-controller="related-element"
data-related-element-highlight-class="event--related">
<% if day_timeline.has_activity? %>
<h2 class="events__day-header">
<span class="events__day-time min-width max-width">
<span class="overflow-ellipsis">
<span>Highlights for</span>
<%= local_datetime_tag day_timeline.day, style: :agoorweekday %>
</span>
</span>
</h2>
<%= render "events/day_timeline/summary", day_timeline: day_timeline if day_timeline.summarizable?%>
<div class="events__columns">
<%= 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 %>
</div>
<%= 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 %>
@@ -0,0 +1,25 @@
<% if day_timeline.has_weekly_highlights? %>
<h2 class="events__day-header">
<span class="events__day-time min-width max-width">
<span class="overflow-ellipsis">
<span>Weekly Highlights for</span>
<%= local_datetime_tag day_timeline.week_starts_at, style: :shortdate %>
<%= local_datetime_tag day_timeline.week_ends_at - 1.day, style: :shortdate %>
</span>
</span>
</h2>
<div class="events__activity-summary txt-small">
<div>
<%= 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" %>
<span class="for-screen-reader">Prompt sandbox</span>
<% end %>
<% end %>
</div>
</div>
<% end %>
@@ -1,3 +0,0 @@
<div id="<%= summary.key %>">
<%= summary.to_html %>
</div>
@@ -1,17 +0,0 @@
<div class="events__activity-summary txt-small">
<% 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" %>
<span class="for-screen-reader">Prompt sandbox</span>
<% end %>
<% end %>
<% else %>
<div class="events__activity--generating" id="<%= day_timeline.summary_key %>">
<%= auto_submit_form_with url: events_activity_summaries_path(day: day_timeline.day, **day_timeline.filter.as_params), method: :post %>
<span class="events__activity-generating-msg">Writing summary…</span>
</div>
<% end %>
</div>
+1
View File
@@ -9,6 +9,7 @@
</header>
<%= render "layouts/shared/flash" %>
<%= render "layouts/shared/time_zone" if Current.user %>
<main id="main">
<%= yield %>
@@ -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 %>
+3
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
@@ -0,0 +1,5 @@
class DropEventActivitySummaries < ActiveRecord::Migration[8.1]
def change
drop_table :event_activity_summaries
end
end
@@ -0,0 +1,5 @@
class AddTimezoneNameToUserSettings < ActiveRecord::Migration[8.1]
def change
add_column :user_settings, :timezone_name, :string
end
end
@@ -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
Generated
+12 -10
View File
@@ -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"
+79 -62
View File
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
+8
View File
@@ -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
+27
View File
@@ -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
@@ -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
+30
View File
@@ -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
+17
View File
@@ -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/, "<TIME>")
end
end
config.register_request_matcher :body_without_times do |r1, r2|
b1 = (r1.body || "").gsub(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/, "<TIME>")
b2 = (r2.body || "").gsub(/\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} UTC/, "<TIME>")
b1 == b2
end
config.default_cassette_options = {
match_requests_on: [ :method, :uri, :body_without_times ]
}
end
module ActiveSupport
-8
View File
@@ -37,12 +37,4 @@ module VcrTestHelper
def without_vcr_body_matching(&block)
VCR.use_cassette("#{@casette_name}_without_body", match_requests_on: [ :method, :uri ], &block)
end
def freeze_timestamps(models = [ Event, Card, Comment, Closure ], at: Time.zone.parse("2025-08-12 9am"))
# Make sure we fix dates since they change the prompt and this gets VCR confused
freeze_time at
models.each do |klass|
klass.update_all created_at: at
end
end
end
@@ -1481,7 +1481,7 @@ http_interactions:
message: OK
headers:
Date:
- Tue, 26 Aug 2025 10:30:56 GMT
- Thu, 04 Sep 2025 17:51:19 GMT
Content-Type:
- application/json
Transfer-Encoding:
@@ -1493,34 +1493,34 @@ http_interactions:
Openai-Organization:
- 37signals-u7qhwk
Openai-Processing-Ms:
- '719'
- '842'
Openai-Project:
- proj_M0FFIqFFeNEpzhhgv64oFzt1
Openai-Version:
- '2020-10-01'
X-Envoy-Upstream-Service-Time:
- '808'
- '855'
X-Ratelimit-Limit-Requests:
- '5000'
- '10000'
X-Ratelimit-Limit-Tokens:
- '4000000'
- '10000000'
X-Ratelimit-Remaining-Requests:
- '4999'
- '9999'
X-Ratelimit-Remaining-Tokens:
- '3999139'
- '9999139'
X-Ratelimit-Reset-Requests:
- 12ms
- 6ms
X-Ratelimit-Reset-Tokens:
- 12ms
- 5ms
X-Request-Id:
- req_20183334d6b94e72bac2921c05ae38b2
- req_3f88573d2fb94e1a8fd5e2546e6d4884
Cf-Cache-Status:
- DYNAMIC
Set-Cookie:
- __cf_bm=RdZDFAbh7mA5HuyEpFQguUDNJK4HvOp..y0DDfrcngY-1756204256-1.0.1.1-S3bFbb3HgUmiJ37hoCG9xk2T61BMznKvx96AlbtrZ5bCNE3DAVWHb4Eh85R3Z16zyHYOioxHRQpeouYiPsemN9Dkk4AauxVV_K2DPdwng5A;
path=/; expires=Tue, 26-Aug-25 11:00:56 GMT; domain=.api.openai.com; HttpOnly;
- __cf_bm=DcHIndXyrXjMEgbbmfJfnXcuZHSWlwhqYx_iJeK_wNo-1757008279-1.0.1.1-9spHTiopGw4asQPQ9aZI6.bO.CowCWwb_g7hhMDGLHo9CIvgznIlb0gPthqnqoanvA_P0MN_20sF.NhX3oeELutzGqIGC59MjNv2.INprI0;
path=/; expires=Thu, 04-Sep-25 18:21:19 GMT; domain=.api.openai.com; HttpOnly;
Secure; SameSite=None
- _cfuvid=F437aickOCA9EMb94y9J7Hq_0TGycpnNyUnFFFTaG6Y-1756204256257-0.0.1.1-604800000;
- _cfuvid=J.ZQLigvt9QD0I5jmOZNwgJBpHHmaaxgo_Ja1TbQDQ0-1757008279361-0.0.1.1-604800000;
path=/; domain=.api.openai.com; HttpOnly; Secure; SameSite=None
Strict-Transport-Security:
- max-age=31536000; includeSubDomains; preload
@@ -1529,33 +1529,32 @@ http_interactions:
Server:
- cloudflare
Cf-Ray:
- 9752a80bdac26c86-ZAG
- 979f55869cb683a5-DFW
Alt-Svc:
- h3=":443"; ma=86400
body:
encoding: ASCII-8BIT
base64_string: |
ewogICJpZCI6ICJjaGF0Y21wbC1DOGxGMUw2YWVXZlBvOE1DUVppbkxqT3Ax
VVh4NyIsCiAgIm9iamVjdCI6ICJjaGF0LmNvbXBsZXRpb24iLAogICJjcmVh
dGVkIjogMTc1NjIwNDI1NSwKICAibW9kZWwiOiAiZ3B0LTQuMS1taW5pLTIw
ewogICJpZCI6ICJjaGF0Y21wbC1DQzhQOEVubnJycGZJVGR6Sm85MW42NFF0
MG9lWSIsCiAgIm9iamVjdCI6ICJjaGF0LmNvbXBsZXRpb24iLAogICJjcmVh
dGVkIjogMTc1NzAwODI3OCwKICAibW9kZWwiOiAiZ3B0LTQuMS1taW5pLTIw
MjUtMDQtMTQiLAogICJjaG9pY2VzIjogWwogICAgewogICAgICAiaW5kZXgi
OiAwLAogICAgICAibWVzc2FnZSI6IHsKICAgICAgICAicm9sZSI6ICJhc3Np
c3RhbnQiLAogICAgICAgICJjb250ZW50IjogIknigJltIG5vdCBzdXJlIGFi
b3V0IHRoYXQgb25l4oCUSSBjYW4gb25seSBhbnN3ZXIgcXVlc3Rpb25zIHJl
bGF0ZWQgdG8geW91ciBGaXp6eSBkYXRhLCBsaWtlIGNhcmRzLCBjb2xsZWN0
aW9ucywgY29tbWVudHMsIG9yIHRlYW0gYWN0aXZpdHkuIiwKICAgICAgICAi
cmVmdXNhbCI6IG51bGwsCiAgICAgICAgImFubm90YXRpb25zIjogW10KICAg
ICAgfSwKICAgICAgImxvZ3Byb2JzIjogbnVsbCwKICAgICAgImZpbmlzaF9y
ZWFzb24iOiAic3RvcCIKICAgIH0KICBdLAogICJ1c2FnZSI6IHsKICAgICJw
cm9tcHRfdG9rZW5zIjogMTkwMywKICAgICJjb21wbGV0aW9uX3Rva2VucyI6
IDMxLAogICAgInRvdGFsX3Rva2VucyI6IDE5MzQsCiAgICAicHJvbXB0X3Rv
a2Vuc19kZXRhaWxzIjogewogICAgICAiY2FjaGVkX3Rva2VucyI6IDAsCiAg
ICAgICJhdWRpb190b2tlbnMiOiAwCiAgICB9LAogICAgImNvbXBsZXRpb25f
dG9rZW5zX2RldGFpbHMiOiB7CiAgICAgICJyZWFzb25pbmdfdG9rZW5zIjog
MCwKICAgICAgImF1ZGlvX3Rva2VucyI6IDAsCiAgICAgICJhY2NlcHRlZF9w
cmVkaWN0aW9uX3Rva2VucyI6IDAsCiAgICAgICJyZWplY3RlZF9wcmVkaWN0
aW9uX3Rva2VucyI6IDAKICAgIH0KICB9LAogICJzZXJ2aWNlX3RpZXIiOiAi
ZGVmYXVsdCIsCiAgInN5c3RlbV9maW5nZXJwcmludCI6ICJmcF8zZjU4ZDEx
MmY3Igp9Cg==
recorded_at: Tue, 26 Aug 2025 10:30:56 GMT
c3RhbnQiLAogICAgICAgICJjb250ZW50IjogIknigJltIG5vdCBzdXJlLiBJ
IGNhbiBvbmx5IGFuc3dlciBxdWVzdGlvbnMgcmVsYXRlZCB0byBGaXp6eSwg
eW91ciBjYXJkcywgY29sbGVjdGlvbnMsIHRlYW0gYWN0aXZpdHksIGFuZCBy
ZWxhdGVkIGRhdGEuIiwKICAgICAgICAicmVmdXNhbCI6IG51bGwsCiAgICAg
ICAgImFubm90YXRpb25zIjogW10KICAgICAgfSwKICAgICAgImxvZ3Byb2Jz
IjogbnVsbCwKICAgICAgImZpbmlzaF9yZWFzb24iOiAic3RvcCIKICAgIH0K
ICBdLAogICJ1c2FnZSI6IHsKICAgICJwcm9tcHRfdG9rZW5zIjogMTkwMywK
ICAgICJjb21wbGV0aW9uX3Rva2VucyI6IDI4LAogICAgInRvdGFsX3Rva2Vu
cyI6IDE5MzEsCiAgICAicHJvbXB0X3Rva2Vuc19kZXRhaWxzIjogewogICAg
ICAiY2FjaGVkX3Rva2VucyI6IDE3OTIsCiAgICAgICJhdWRpb190b2tlbnMi
OiAwCiAgICB9LAogICAgImNvbXBsZXRpb25fdG9rZW5zX2RldGFpbHMiOiB7
CiAgICAgICJyZWFzb25pbmdfdG9rZW5zIjogMCwKICAgICAgImF1ZGlvX3Rv
a2VucyI6IDAsCiAgICAgICJhY2NlcHRlZF9wcmVkaWN0aW9uX3Rva2VucyI6
IDAsCiAgICAgICJyZWplY3RlZF9wcmVkaWN0aW9uX3Rva2VucyI6IDAKICAg
IH0KICB9LAogICJzZXJ2aWNlX3RpZXIiOiAiZGVmYXVsdCIsCiAgInN5c3Rl
bV9maW5nZXJwcmludCI6ICJmcF80ZmNlMDc3OGFmIgp9Cg==
recorded_at: Thu, 04 Sep 2025 17:51:19 GMT
recorded_with: VCR 6.3.1
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff