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? %> -

- - - Highlights for - <%= local_datetime_tag day_timeline.day, style: :agoorweekday %> - - -

- - <%= 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? %> +

+ + + Weekly Highlights for + <%= local_datetime_tag day_timeline.week_starts_at, style: :shortdate %> + – + <%= local_datetime_tag day_timeline.week_ends_at - 1.day, style: :shortdate %> + + +

+ +
+
+ <%= 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/, "