Merge pull request #1007 from basecamp/weekly-highlights
Weekly highlights
This commit is contained in:
@@ -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,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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 <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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
@@ -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?
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 %>
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+1143
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+1894
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user