diff --git a/app/assets/images/history.svg b/app/assets/images/history.svg new file mode 100644 index 000000000..f36d73631 --- /dev/null +++ b/app/assets/images/history.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/colors.css b/app/assets/stylesheets/colors.css index 0be8c4ee4..135b0d0cc 100644 --- a/app/assets/stylesheets/colors.css +++ b/app/assets/stylesheets/colors.css @@ -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; } } diff --git a/app/assets/stylesheets/events.css b/app/assets/stylesheets/events.css new file mode 100644 index 000000000..0dec910b7 --- /dev/null +++ b/app/assets/stylesheets/events.css @@ -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)); +} diff --git a/app/assets/stylesheets/inputs.css b/app/assets/stylesheets/inputs.css index eb4fbc821..5b8a9a6db 100644 --- a/app/assets/stylesheets/inputs.css +++ b/app/assets/stylesheets/inputs.css @@ -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 { diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c54063eef..4f75453ff 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,5 +1,5 @@ class ApplicationController < ActionController::Base - include Authentication + include Authentication, CurrentTimezone stale_when_importmap_changes allow_browser versions: :modern diff --git a/app/controllers/concerns/current_timezone.rb b/app/controllers/concerns/current_timezone.rb new file mode 100644 index 000000000..9c36757c8 --- /dev/null +++ b/app/controllers/concerns/current_timezone.rb @@ -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 diff --git a/app/controllers/events_controller.rb b/app/controllers/events_controller.rb new file mode 100644 index 000000000..60c3d03cf --- /dev/null +++ b/app/controllers/events_controller.rb @@ -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 diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb new file mode 100644 index 000000000..91c329a94 --- /dev/null +++ b/app/helpers/events_helper.rb @@ -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 diff --git a/app/javascript/controllers/timezone_cookie_controller.js b/app/javascript/controllers/timezone_cookie_controller.js new file mode 100644 index 000000000..f469a3f8f --- /dev/null +++ b/app/javascript/controllers/timezone_cookie_controller.js @@ -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=/` + } +} diff --git a/app/models/bubble/eventable.rb b/app/models/bubble/eventable.rb index ccd085870..927ac1f25 100644 --- a/app/models/bubble/eventable.rb +++ b/app/models/bubble/eventable.rb @@ -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 diff --git a/app/models/event.rb b/app/models/event.rb index 8dfceeeeb..28f703576 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -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 diff --git a/app/views/buckets/index.html.erb b/app/views/buckets/index.html.erb index 7c2f59a91..97353daaf 100644 --- a/app/views/buckets/index.html.erb +++ b/app/views/buckets/index.html.erb @@ -2,11 +2,16 @@ <% content_for :header do %>