Merge pull request #206 from basecamp/latest-activity

Latest activity
This commit is contained in:
Kevin McConnell
2025-01-28 17:43:06 +00:00
committed by GitHub
25 changed files with 391 additions and 8 deletions
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 20C16.4 20 20 16.4 20 12S16.4 4 12 4 4 7.6 4 12 7.6 20 12 20M12 2C17.5 2 22 6.5 22 12S17.5 22 12 22C6.5 22 2 17.5 2 12C2 6.5 6.5 2 12 2M17 13.9L16.3 15.2L11 12.3V7H12.5V11.4L17 13.9Z" /></svg>

After

Width:  |  Height:  |  Size: 265 B

+3
View File
@@ -12,6 +12,7 @@
--lch-red: 51% 0.2 31;
--lch-green: 65.59% 0.234 142.49;
--lch-green-light: 95% 0.03 142.49;
--lch-yellow: 96.01% 0.0348 81.29;
--lch-always-black: 0% 0 0;
--lch-always-white: 100% 0 0;
@@ -28,6 +29,7 @@
--color-subtle-dark: oklch(var(--lch-gray-dark));
--color-selected: oklch(var(--lch-blue-light));
--color-selected-dark: oklch(var(--lch-blue-dark));
--color-highlight: oklch(var(--lch-yellow));
--color-marker: oklch(var(--lch-orange));
--color-always-black: oklch(var(--lch-always-black));
--color-always-white: oklch(var(--lch-always-white));
@@ -45,6 +47,7 @@
--lch-red: 73.8% 0.184 29.18;
--lch-green: 75% 0.21 141.89;
--lch-green-light: 28.11% 0.02 142.49;
--lch-yellow: 35.29% 0.0493 84.59;
}
}
+77
View File
@@ -0,0 +1,77 @@
.events {
--grid-lines: 3px;
border-block-start: 1px solid var(--color-subtle);
display: grid;
gap: var(--grid-lines);
grid-template-columns: repeat(4, 1fr);
padding-block-start: var(--block-space);
}
.event--cluster {
&:has(.event + .event) {
display: grid;
gap: var(--inline-space);
padding-block: var(--block-space-half);
.event {
grid-column-start: unset !important;
grid-row-start: unset !important;
}
}
}
.event {
--column-gap: 0.7ch;
--panel-border-radius: 0.7em;
--panel-padding: 0.6em;
--panel-size: auto;
margin: var(--inline-space);
}
.events--none {
.events + & {
padding-block-start: calc(4 * var(--block-space));
}
}
.event-grid-item {
background-color: var(--color-selected);
block-size: 100%;
border-radius: var(--grid-lines);
display: flex;
inline-size: 100%;
min-block-size: var(--block-space);
&:nth-child(1n + 53) {
background-color: var(--color-highlight);
}
&.current-hour {
background-color: var(--color-selected-dark);
}
}
.event-grid-time {
position: relative;
time {
align-items: center;
background: var(--color-bg);
border-radius: 2em;
display: inline-flex;
font-size: var(--text-small);
font-weight: 600;
inline-size: max-content;
margin: 0 auto;
padding: 0 var(--inline-space-double);
position: absolute;
text-transform: uppercase;
transform: translate(-50%, 0);
}
}
.events__index {
--row-gap: calc(4 * var(--block-space));
}
+13 -2
View File
@@ -79,9 +79,20 @@
}
.input--select {
--input-padding: 0.5em 0em 0.5em 0.7em;
--input-border-radius: 2em;
--input-padding: 0.5em 1.8em 0.5em 1.2em;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23000'/%3E%3C/svg%3E");
background-size: 0.5em;
background-position: center right 0.9em;
background-repeat: no-repeat;
text-align: start;
@media (prefers-color-scheme: dark) {
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='m12 19.5c-.7 0-1.3-.3-1.7-.8l-9.8-11.1c-.7-.8-.6-1.9.2-2.6.8-.6 1.9-.6 2.5.2l8.6 9.8c0 .1.2.1.4 0l8.6-9.8c.7-.8 1.8-.9 2.6-.2s.9 1.8.2 2.6l-9.8 11.1c-.4.5-1.1.8-1.7.8z' fill='%23fff'/%3E%3C/svg%3E");
}
}
.input--textara {
+1 -1
View File
@@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
include Authentication
include Authentication, CurrentTimezone
stale_when_importmap_changes
allow_browser versions: :modern
@@ -0,0 +1,17 @@
module CurrentTimezone
extend ActiveSupport::Concern
included do
around_action :set_current_timezone
end
private
def set_current_timezone(&)
Time.use_zone(timezone_from_cookie, &)
end
def timezone_from_cookie
timezone = cookies[:timezone]
timezone if timezone.present? && ActiveSupport::TimeZone[timezone]
end
end
+37
View File
@@ -0,0 +1,37 @@
class EventsController < ApplicationController
before_action :set_activity_day
def index
@events = unique_events_by_hour_and_column
@next_day = latest_event_before_today&.created_at
end
private
def unique_events_by_hour_and_column
user_events.where(created_at: @activity_day.all_day).
group_by { |event| [ event.created_at.hour, helpers.event_column(event) ] }.
map { |hour_col, events| [ hour_col, events.uniq(&:bubble_id) ] }
end
def latest_event_before_today
user_events.where(created_at: ...@activity_day.beginning_of_day).chronologically.last
end
def user_events
Event.where(bubble: user_bubbles)
end
def user_bubbles
Current.user.accessible_bubbles.published_or_drafted_by(Current.user)
end
def set_activity_day
@activity_day = if params[:day].present?
Time.zone.parse(params[:day])
else
Time.zone.now
end
rescue ArgumentError
raise ActionController::RoutingError
end
end
+71
View File
@@ -0,0 +1,71 @@
module EventsHelper
def event_day_title(day)
case
when day.today?
"Today"
when day.yesterday?
"Yesterday"
else
day.strftime("%A, %B %e")
end
end
def event_column(event)
case event.action
when "popped"
4
when "published"
3
when "commented"
2
else
1
end
end
def event_cluster_tag(hour, col, &)
row = 25 - hour
tag.div class: "event--cluster", style: "grid-area: #{row}/#{col}", &
end
def event_next_page_link(next_day)
if next_day
tag.div id: "next_page",
data: { controller: "fetch-on-visible", fetch_on_visible_url_value: events_path(day: next_day.strftime("%Y-%m-%d")) }
end
end
def render_event_grid_cells(day, columns: 4, rows: 24)
safe_join((2..rows + 1).map do |row|
current_hour = row == 25 - Time.current.hour && day.today?
(1..columns).map do |col|
tag.div class: class_names("event-grid-item", "current-hour": current_hour), style: "grid-area: #{row}/#{col};"
end
end.flatten)
end
def render_time_labels(day)
safe_join((0..24).step(6).to_a.map do |hour|
time = day.beginning_of_day + hour.hours
label = if hour == 24
"midnight"
else
time.strftime("%l:%M %P").strip
end
content_tag(:div,
content_tag(:time,
label,
datetime: time.strftime("%H:%M")
),
class: "event-grid-time",
style: "grid-area: #{25 - (hour * 23/24)}/2 / #{25 - (hour * 23/24)}/4;"
)
end)
end
def render_column_headers
[ "Touched", "Discussed", "Added", "Popped" ].map do |header|
content_tag(:h3, header, class: "event-grid-column-title margin-block-end-half")
end.join.html_safe
end
end
@@ -0,0 +1,12 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
connect() {
this.#setTimezoneCookie()
}
#setTimezoneCookie() {
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
document.cookie = `timezone=${encodeURIComponent(timezone)}; path=/`
}
}
+5 -1
View File
@@ -1,9 +1,13 @@
module Bubble::Eventable
extend ActiveSupport::Concern
included do
has_many :events, dependent: :destroy
end
private
def track_event(action, creator: Current.user, **particulars)
event = find_or_capture_event_summary.events.create! action: action, creator: creator, particulars: particulars
event = find_or_capture_event_summary.events.create! action: action, creator: creator, bubble: self, particulars: particulars
event.generate_notifications_later
end
+1
View File
@@ -3,6 +3,7 @@ class Event < ApplicationRecord
belongs_to :creator, class_name: "User"
belongs_to :summary, touch: true, class_name: "EventSummary"
belongs_to :bubble
has_one :account, through: :creator
+6 -1
View File
@@ -2,11 +2,16 @@
<% content_for :header do %>
<nav>
<%= link_to account_users_path, class: "btn flex-item-justify-start" do %>
<%= link_to account_users_path, class: "btn" do %>
<%= image_tag "settings.svg", aria: { hidden: true }, size: 24 %>
<span class="for-screen-reader">Account settings</span>
<% end %>
<%= link_to events_path, class: "btn flex-item-justify-start" do %>
<%= image_tag "history.svg", aria: { hidden: true }, size: 24 %>
<span class="for-screen-reader">Latest activity</span>
<% end %>
<%= link_to new_bucket_path, class: "btn flex-item-justify-end", style: "view-transition-name: new-bucket" do %>
<%= image_tag "add.svg", aria: { hidden: true }, size: 24 %>
<span class="for-screen-reader">Add a new project</span>
+21
View File
@@ -0,0 +1,21 @@
<div class="events--day">
<% if events.any? %>
<h2 class="txt-x-large margin-block-end"><%= event_day_title(activity_day) %></h2>
<div class="center events">
<%= render_column_headers %>
<%= render_event_grid_cells(activity_day) %>
<%= render_time_labels(activity_day) %>
<% events.each do | hour_column, cluster| %>
<%= event_cluster_tag(*hour_column) do %>
<%= render cluster %>
<% end %>
<% end %>
</div>
<%= render "events/empty_days", earliest: next_day&.tomorrow&.beginning_of_day, latest: activity_day.yesterday.beginning_of_day %>
<% else %>
<%= render "events/empty_days", earliest: next_day&.tomorrow&.beginning_of_day, latest: activity_day.beginning_of_day %>
<% end %>
</div>
+15
View File
@@ -0,0 +1,15 @@
<% if earliest.present? %>
<% if earliest == latest %>
<div class="events--none txt-align-center translucent">
<h2 class="txt-medium margin-none"><%= event_day_title(earliest) %></h2>
<p class="margin-none">No activity</p>
</div>
<% elsif earliest < latest %>
<div class="events--none txt-align-center translucent">
<h2 class="txt-medium margin-none"><%= event_day_title(earliest) %> <%= event_day_title(latest) %></h2>
<p class="margin-none">No activity for <%= (latest - earliest).seconds.in_days.round + 1 %> days</p>
</div>
<% end %>
<% else %>
<p class="events--none txt-align-center translucent">No more activity.</p>
<% end %>
+12
View File
@@ -0,0 +1,12 @@
<% bubble = event.summary.bubble %>
<%= link_to bucket_bubble_path(bubble.bucket, bubble),
class: "event panel shadow center center-block flex-inline align-center justify-start gap position-relative",
style: "--bubble-color: #{ bubble.color }; #{ bubble_rotation(bubble) }",
data: { controller: "animation", animation_play_class: "bubble--wobble", animation_play_on_load_value: "true", action: "mouseover->animation#play" } do %>
<div class="bubble__shape flex-item-no-shrink txt-large"></div>
<div class="flex flex-column min-width txt-small txt-tight-lines align-start txt-align-start">
<strong><%= bubble.title %></strong>
<span class="txt-small translucent"><%= bubble.bucket.name %></span>
</div>
<% end %>
+24
View File
@@ -0,0 +1,24 @@
<% @page_title = "Latest Activity" %>
<% content_for :header do %>
<nav>
<%= link_to root_path, class: "btn flex-item-justify-start", data: { controller: "hotkey", action: "keydown.esc@document->hotkey#click" } do %>
<%= image_tag "arrow-left.svg", aria: { hidden: true }, size: 24 %>
<span class="for-screen-reader">Go back</span>
<% end %>
<header class="txt-align-center">
<h1 class="txt-large flex align-center gap-half">
<%= @page_title %>
</h1>
</header>
<span class="btn btn--placeholder flex-item-justify-end"></span>
</nav>
<% end %>
<div id="activity" class="events__index flex flex-column gap">
<%= render "events/day", activity_day: @activity_day, next_day: @next_day, events: @events %>
</div>
<%= event_next_page_link(@next_day) %>
+7
View File
@@ -0,0 +1,7 @@
<%= turbo_stream.append :activity do %>
<%= render "events/day", activity_day: @activity_day, next_day: @next_day, events: @events %>
<% end %>
<%= turbo_stream.replace :next_page do %>
<%= event_next_page_link(@next_day) %>
<% end %>
+1 -1
View File
@@ -19,7 +19,7 @@
<%= yield :head %>
</head>
<body data-controller="lightbox local-time">
<body data-controller="lightbox local-time timezone-cookie">
<header id="header">
<a href="#main-content" class="skip-navigation btn">Skip to main content</a>
<%= yield :header %>
+3
View File
@@ -74,4 +74,7 @@ Rails.application.configure do
# Raise error when a before_action's only/except options reference missing actions
config.action_controller.raise_on_missing_callback_actions = true
# Allow all hosts in development
config.hosts = nil
end
+1
View File
@@ -48,6 +48,7 @@ Rails.application.routes.draw do
end
end
resources :events
resources :filters
resource :first_run
resources :qr_codes
@@ -0,0 +1,17 @@
class AssociateEventsWithBubbles < ActiveRecord::Migration[8.1]
def change
change_table :events do |t|
t.references :bubble, foreign_key: true
end
execute "
update events
set bubble_id = m.bubble_id
from event_summaries es
join messages m on m.messageable_type = 'EventSummary' and m.messageable_id = es.id
where events.summary_id = es.id
"
change_column_null :events, :bubble_id, false
end
end
Generated
+4 -1
View File
@@ -147,6 +147,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_21_174109) do
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.integer "summary_id", null: false
t.integer "bubble_id", null: false
t.index ["bubble_id"], name: "index_events_on_bubble_id"
t.index ["creator_id"], name: "index_events_on_creator_id"
t.index ["summary_id", "action"], name: "index_events_on_summary_id_and_action"
end
@@ -266,6 +268,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_21_174109) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "bubbles", "workflow_stages", column: "stage_id"
add_foreign_key "events", "bubbles"
add_foreign_key "events", "event_summaries", column: "summary_id"
add_foreign_key "messages", "bubbles"
add_foreign_key "notifications", "bubbles"
@@ -284,4 +287,4 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_21_174109) do
# Note that virtual tables may not work with other database engines. Be careful if changing database.
create_virtual_table "bubbles_search_index", "fts5", ["title"]
create_virtual_table "comments_search_index", "fts5", ["body"]
end
end
@@ -0,0 +1,25 @@
require "test_helper"
class EventsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in_as :kevin
end
test "index" do
get events_url
assert_select "div.event--cluster[style='grid-area: 9/1']" do
assert_select "strong", text: "Layout is broken"
end
end
test "index with a specific timezone" do
cookies[:timezone] = "America/New_York"
get events_url
assert_select "div.event--cluster[style='grid-area: 14/1']" do
assert_select "strong", text: "Layout is broken"
end
end
end
+14
View File
@@ -1,11 +1,13 @@
logo_published:
creator: david
bubble: logo
action: published
summary: logo_initial_activity
created_at: <%= 1.week.ago %>
logo_assignment_jz:
creator: david
bubble: logo
action: assigned
summary: logo_initial_activity
particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:jz) ] }.to_json %>
@@ -13,12 +15,14 @@ logo_assignment_jz:
logo_boost_dhh:
creator: david
bubble: logo
action: boosted
summary: logo_initial_activity
created_at: <%= 1.week.ago + 2.hours %>
logo_assignment_km:
creator: david
bubble: logo
action: assigned
summary: logo_second_activity
particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:kevin) ] }.to_json %>
@@ -26,36 +30,42 @@ logo_assignment_km:
logo_boost_km1:
creator: kevin
bubble: logo
action: boosted
summary: logo_second_activity
created_at: <%= 1.day.ago + 1.hour %>
logo_boost_km2:
creator: kevin
bubble: logo
action: boosted
summary: logo_second_activity
created_at: <%= 1.day.ago + 2.hours %>
logo_boost_jz1:
creator: jz
bubble: logo
action: boosted
summary: logo_second_activity
created_at: <%= 1.day.ago + 3.hours %>
logo_boost_jz2:
creator: jz
bubble: logo
action: boosted
summary: logo_third_activity
created_at: <%= 1.hour.ago %>
layout_published:
creator: david
bubble: layout
action: published
summary: layout_initial_activity
created_at: <%= 1.week.ago %>
layout_commented:
creator: david
bubble: layout
action: commented
summary: layout_initial_activity
particulars: <%= { comment_id: ActiveRecord::FixtureSet.identify(:layout_overflowing_david) }.to_json %>
@@ -63,6 +73,7 @@ layout_commented:
layout_assignment_jz:
creator: david
bubble: layout
action: assigned
summary: layout_initial_activity
particulars: <%= { assignee_ids: [ ActiveRecord::FixtureSet.identify(:jz) ] }.to_json %>
@@ -70,18 +81,21 @@ layout_assignment_jz:
text_published:
creator: kevin
bubble: text
action: published
summary: text_initial_activity
created_at: <%= 1.week.ago %>
shipping_published:
creator: kevin
bubble: shipping
action: published
summary: shipping_initial_activity
created_at: <%= 1.week.ago %>
shipping_popped:
creator: kevin
bubble: shipping
action: popped
summary: shipping_initial_activity
created_at: <%= 2.days.ago %>
+3 -1
View File
@@ -8,7 +8,9 @@ class Notifier::AssignedTest < ActiveSupport::TestCase
end
test "does not notify for self-assignments" do
event = EventSummary.last.events.create! action: :assigned,
event = EventSummary.last.events.create! \
action: :assigned,
bubble: bubbles(:logo),
creator: users(:kevin),
particulars: { assignee_ids: [ users(:kevin).id ] }