atomic_view 0.1.18 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/Rakefile +2 -0
- data/app/assets/javascripts/atomic_view/controllers/gantt_controller.js +199 -0
- data/lib/atomic_view/components/datetime_select_component.rb +11 -0
- data/lib/atomic_view/components/dropdown_component.html.erb +8 -1
- data/lib/atomic_view/components/dropdown_component.rb +21 -1
- data/lib/atomic_view/components/gantt_component/date_header_component.html.erb +4 -0
- data/lib/atomic_view/components/gantt_component/date_header_component.rb +60 -0
- data/lib/atomic_view/components/gantt_component/item_component.html.erb +7 -0
- data/lib/atomic_view/components/gantt_component/item_component.rb +194 -0
- data/lib/atomic_view/components/gantt_component/month_band_component.html.erb +3 -0
- data/lib/atomic_view/components/gantt_component/month_band_component.rb +78 -0
- data/lib/atomic_view/components/gantt_component/row_component.html.erb +22 -0
- data/lib/atomic_view/components/gantt_component/row_component.rb +84 -0
- data/lib/atomic_view/components/gantt_component.html.erb +76 -0
- data/lib/atomic_view/components/gantt_component.rb +486 -0
- data/lib/atomic_view/version.rb +1 -1
- metadata +13 -2
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
class GanttComponent
|
|
6
|
+
# A single resource row within a `GanttComponent` -- a label (e.g. a
|
|
7
|
+
# site code) plus a horizontally-scrolling track that its `ItemComponent`
|
|
8
|
+
# bars position themselves within. Rendered via `GanttComponent#with_row`
|
|
9
|
+
# -- see that class's docs for the full picture, including what
|
|
10
|
+
# `origin:` means and why it has to stay consistent across requests.
|
|
11
|
+
#
|
|
12
|
+
# `track_id` is the id a `next_dates_path`/`prev_dates_path` Turbo
|
|
13
|
+
# Stream response `append`s/`prepend`s new bars to once more date
|
|
14
|
+
# columns load -- this row's own markup doesn't otherwise get
|
|
15
|
+
# re-rendered when that happens.
|
|
16
|
+
class RowComponent < AtomicView::Component
|
|
17
|
+
renders_many :items, "AtomicView::Components::GanttComponent::ItemComponent"
|
|
18
|
+
|
|
19
|
+
attr_reader :id, :label, :sublabel, :href, :origin, :cell_width, :label_width, :lanes, :today
|
|
20
|
+
|
|
21
|
+
# @param id [String] unique DOM id for this row.
|
|
22
|
+
# @param label [String] primary label (e.g. a site code).
|
|
23
|
+
# @param sublabel [String, nil] secondary label under it (e.g. a park name).
|
|
24
|
+
# @param href [String, nil] wraps the label in a link when given.
|
|
25
|
+
# @param origin [Date] must match the `origin:` given to every
|
|
26
|
+
# `ItemComponent` in this row (and every other row in this grid) --
|
|
27
|
+
# used here only for the optional `today:` highlight strip.
|
|
28
|
+
# @param cell_width [Integer] must match the parent `GanttComponent`'s.
|
|
29
|
+
# @param label_width [Integer] must match the parent `GanttComponent`'s.
|
|
30
|
+
# @param lanes [Integer] how many horizontal lanes of overlapping
|
|
31
|
+
# items this row's track should reserve height for -- `1` (the
|
|
32
|
+
# default) fits a row with no overlaps. See
|
|
33
|
+
# `ItemComponent#lane`/`GanttComponent.pack_lanes`.
|
|
34
|
+
# @param today [Date, nil] renders a highlighted strip at this date's
|
|
35
|
+
# column, when given (typically threaded through from the parent
|
|
36
|
+
# `GanttComponent#today`).
|
|
37
|
+
def initialize(
|
|
38
|
+
id:,
|
|
39
|
+
label:,
|
|
40
|
+
origin:,
|
|
41
|
+
sublabel: nil,
|
|
42
|
+
href: nil,
|
|
43
|
+
cell_width: GanttComponent::DEFAULT_CELL_WIDTH,
|
|
44
|
+
label_width: GanttComponent::DEFAULT_LABEL_WIDTH,
|
|
45
|
+
lanes: 1,
|
|
46
|
+
today: nil,
|
|
47
|
+
**options
|
|
48
|
+
)
|
|
49
|
+
super()
|
|
50
|
+
@id = id
|
|
51
|
+
@label = label
|
|
52
|
+
@sublabel = sublabel
|
|
53
|
+
@href = href
|
|
54
|
+
@origin = origin
|
|
55
|
+
@cell_width = cell_width
|
|
56
|
+
@label_width = label_width
|
|
57
|
+
@lanes = lanes
|
|
58
|
+
@today = today
|
|
59
|
+
@options = options
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def track_id = "#{id}_track"
|
|
63
|
+
|
|
64
|
+
def html_options
|
|
65
|
+
@options.except(:class)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def html_class
|
|
69
|
+
class_names("flex", @options[:class])
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def track_style
|
|
73
|
+
min_height = GanttComponent::LANE_TOP_PADDING * 2 + (lanes * GanttComponent::LANE_HEIGHT)
|
|
74
|
+
"background-image: repeating-linear-gradient(to right, var(--color-border) 0, var(--color-border) 1px, transparent 1px, transparent #{cell_width}px); min-height: #{min_height}px"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def today_offset_px
|
|
78
|
+
return nil unless today
|
|
79
|
+
GanttComponent.offset_px(today, origin: origin, cell_width: cell_width)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
<%= tag.div(id: id, **html_options, class: html_class, data: data_attributes) do %>
|
|
2
|
+
<% if paginated_dates_backward? %>
|
|
3
|
+
<div class="flex items-center border-b border-border px-3 py-1.5">
|
|
4
|
+
<button
|
|
5
|
+
type="button"
|
|
6
|
+
id="<%= date_start_trigger_id %>"
|
|
7
|
+
class="flex items-center gap-1.5 rounded-btn px-2 py-1 text-xs font-semibold text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
8
|
+
data-atomic-view--gantt-target="dateStartTrigger"
|
|
9
|
+
data-action="click->atomic-view--gantt#loadEarlierDates"
|
|
10
|
+
data-prev-page="<%= prev_dates_path %>"
|
|
11
|
+
>
|
|
12
|
+
<span id="<%= date_start_loader_id %>" class="size-3.5 flex-none animate-spin rounded-full border-2 border-border border-t-foreground motion-reduce:animate-none" data-atomic-view--gantt-target="dateStartLoader" hidden aria-hidden="true"></span>
|
|
13
|
+
Load earlier days
|
|
14
|
+
</button>
|
|
15
|
+
</div>
|
|
16
|
+
<% end %>
|
|
17
|
+
|
|
18
|
+
<div id="<%= scroller_id %>" class="overflow-x-auto" data-atomic-view--gantt-target="scroller">
|
|
19
|
+
<div class="w-max min-w-full">
|
|
20
|
+
<div class="flex">
|
|
21
|
+
<div class="sticky left-0 top-0 z-30 flex-none border-b border-r border-border bg-surface" style="width: <%= label_width %>px"></div>
|
|
22
|
+
|
|
23
|
+
<div class="flex flex-col">
|
|
24
|
+
<div id="<%= month_band_id %>" class="grid h-5 flex-none grid-flow-col border-b border-border/60 bg-surface" style="<%= column_grid_style %>">
|
|
25
|
+
<% month_segments.each do |segment| %>
|
|
26
|
+
<%= render(AtomicView::Components::GanttComponent::MonthBandComponent.new(
|
|
27
|
+
id: "#{month_band_id}_#{segment[:month_start].strftime("%Y-%m")}",
|
|
28
|
+
label: segment[:month_start].strftime("%B %Y"),
|
|
29
|
+
span: segment[:count]
|
|
30
|
+
)) %>
|
|
31
|
+
<% end %>
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
<div id="<%= dates_id %>" class="grid grid-flow-col" style="<%= column_grid_style %>" data-atomic-view--gantt-target="dates">
|
|
35
|
+
<% dates.each do |date| %>
|
|
36
|
+
<%= render(AtomicView::Components::GanttComponent::DateHeaderComponent.new(date: date, today: today)) %>
|
|
37
|
+
<% end %>
|
|
38
|
+
</div>
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<div id="<%= date_loader_id %>" class="flex flex-none items-center justify-center px-3" data-atomic-view--gantt-target="dateLoader" hidden>
|
|
42
|
+
<span class="size-3.5 animate-spin rounded-full border-2 border-border border-t-foreground motion-reduce:animate-none" aria-hidden="true"></span>
|
|
43
|
+
<span class="sr-only">Loading more days…</span>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<% if paginated_dates_forward? %>
|
|
47
|
+
<div
|
|
48
|
+
id="<%= date_sentinel_id %>"
|
|
49
|
+
class="w-px flex-none self-stretch"
|
|
50
|
+
data-atomic-view--gantt-target="dateSentinel"
|
|
51
|
+
data-next-page="<%= next_dates_path %>"
|
|
52
|
+
></div>
|
|
53
|
+
<% end %>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<div id="<%= rows_id %>" class="flex flex-col">
|
|
57
|
+
<% rows.each do |row| %>
|
|
58
|
+
<%= row %>
|
|
59
|
+
<% end %>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<% if empty? %>
|
|
63
|
+
<p class="px-4 py-6 text-center text-sm text-muted-foreground"><%= empty_message %></p>
|
|
64
|
+
<% end %>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
<% if paginated_rows? %>
|
|
69
|
+
<%= tag.turbo_frame(id: row_pagination_id, src: next_rows_path, loading: :lazy, class: "flex items-center justify-center gap-2 border-t border-border py-3") do %>
|
|
70
|
+
<span class="size-3.5 animate-spin rounded-full border-2 border-border border-t-foreground motion-reduce:animate-none" aria-hidden="true"></span>
|
|
71
|
+
<span class="sr-only">Loading more rows…</span>
|
|
72
|
+
<% end %>
|
|
73
|
+
<% else %>
|
|
74
|
+
<%= tag.turbo_frame(id: row_pagination_id) %>
|
|
75
|
+
<% end %>
|
|
76
|
+
<% end %>
|
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AtomicView
|
|
4
|
+
module Components
|
|
5
|
+
# Gantt
|
|
6
|
+
#
|
|
7
|
+
# A resource-scheduling grid -- rows are resources (sites, rooms, staff),
|
|
8
|
+
# columns are days, and each row's bookings render as bars positioned by
|
|
9
|
+
# real date math. Both axes load more data independently via Turbo, no
|
|
10
|
+
# full page reload:
|
|
11
|
+
#
|
|
12
|
+
# render(GanttComponent.new(
|
|
13
|
+
# id: "site-schedule",
|
|
14
|
+
# dates: @dates, # Array<Date> currently loaded/visible
|
|
15
|
+
# next_rows_path: @next_rows_path, # more sites (down)
|
|
16
|
+
# next_dates_path: @next_dates_path # more days (right)
|
|
17
|
+
# )) do |gantt|
|
|
18
|
+
# @sites.each do |site|
|
|
19
|
+
# gantt.with_row(id: dom_id(site, :row), label: site.code, sublabel: site.park.name, origin: @origin) do |row|
|
|
20
|
+
# site.bookings.each do |booking|
|
|
21
|
+
# row.with_item(starts_on: booking.starts_on, ends_on: booking.ends_on, label: booking.camper_name, origin: @origin, variant: :success, href: booking_path(booking))
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
# end
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# Unlike `TimelineComponent` (a vertical activity feed, unrelated to this
|
|
28
|
+
# component despite the shared "timeline" vocabulary in casual use), a
|
|
29
|
+
# Gantt's date columns must line up across every row -- that's the whole
|
|
30
|
+
# point of the chart. So horizontal pagination here is *grid-wide*: one
|
|
31
|
+
# trigger loads more days for every row at once, rather than each row
|
|
32
|
+
# scrolling its own independent strip of items. See `RowComponent` and
|
|
33
|
+
# `ItemComponent` for the per-row/per-bar API.
|
|
34
|
+
#
|
|
35
|
+
# == How the grid is laid out
|
|
36
|
+
#
|
|
37
|
+
# The label column is `sticky left-0`; each row is a flex box of (label,
|
|
38
|
+
# track). Every row's track is `width: 100%` of a `width: max-content`
|
|
39
|
+
# inner wrapper shared with the date header -- so as the header grows
|
|
40
|
+
# (more date cells appended), every row's track grows with it
|
|
41
|
+
# automatically, via ordinary CSS reflow.
|
|
42
|
+
#
|
|
43
|
+
# The date header and month band are each a CSS Grid (`grid-auto-flow:
|
|
44
|
+
# column`, `grid-auto-columns: <cell_width>`) -- one declaration on the
|
|
45
|
+
# container sizes every column, so a `DateHeaderComponent` cell carries no
|
|
46
|
+
# width of its own, and a `MonthBandComponent` segment just declares
|
|
47
|
+
# `grid-column: span <day count>` rather than a computed pixel width. This
|
|
48
|
+
# is deliberately plain flow/grid layout, not client-side column
|
|
49
|
+
# bookkeeping: appending or prepending a cell is nothing more than
|
|
50
|
+
# inserting a DOM node, and the grid places it.
|
|
51
|
+
#
|
|
52
|
+
# Bars are the one thing that *can't* live on that grid -- a booking
|
|
53
|
+
# starting or ending mid-render isn't a concept here (every date is a
|
|
54
|
+
# whole day, and a bar always starts/ends on one), but a bar can still
|
|
55
|
+
# begin before the currently-loaded window, and grid line numbers aren't
|
|
56
|
+
# stable across a `prepend` the way real calendar dates are (see
|
|
57
|
+
# "`origin:` has to round-trip through every request" below). So bars
|
|
58
|
+
# stay `position: absolute` with inline `left`/`width` in pixels, computed
|
|
59
|
+
# from real dates (see `.offset_px`/`.span_px`) rather than a column
|
|
60
|
+
# index or grid line.
|
|
61
|
+
#
|
|
62
|
+
# == `origin:` has to round-trip through every request
|
|
63
|
+
#
|
|
64
|
+
# `RowComponent` and `ItemComponent` both take `origin:` -- the `Date`
|
|
65
|
+
# that renders at pixel 0 (the leftmost edge of the whole grid, not just
|
|
66
|
+
# the currently-loaded page of dates), and should be the same value as
|
|
67
|
+
# `dates.first` from the very first render. Every bar's position is
|
|
68
|
+
# computed relative to it. Pick it once and pass the *same* value on
|
|
69
|
+
# every request for this grid -- the initial render, every
|
|
70
|
+
# `next_rows_path` response, and every `next_dates_path` response --
|
|
71
|
+
# typically by round-tripping it through the pagination URLs as a param
|
|
72
|
+
# (see the worked example below). Getting this wrong doesn't break any
|
|
73
|
+
# single response in isolation; it just misaligns bars appended later
|
|
74
|
+
# against columns rendered earlier.
|
|
75
|
+
#
|
|
76
|
+
# A `prev_dates_path` response prepending earlier days shifts the date
|
|
77
|
+
# header's own grid (new columns land at its start, pushing the rest
|
|
78
|
+
# right) without moving any bar already positioned in pixels against the
|
|
79
|
+
# fixed `origin:` -- the `atomic-view--gantt` Stimulus controller
|
|
80
|
+
# compensates by nudging every already-rendered bar (and the today strip)
|
|
81
|
+
# right by however much width the header just grew by; see that
|
|
82
|
+
# controller's docs.
|
|
83
|
+
#
|
|
84
|
+
# == Wiring this up with real data (Pagy keyset + Turbo Streams)
|
|
85
|
+
#
|
|
86
|
+
# Rows don't need any special interface -- `with_row`/`with_item` just
|
|
87
|
+
# want plain values (a label, dates, a variant), so mapping from
|
|
88
|
+
# ActiveRecord is nothing more than the block above: call whatever
|
|
89
|
+
# reads naturally off your models. The only real design decision is
|
|
90
|
+
# pagination, and the two axes are different enough to solve
|
|
91
|
+
# differently:
|
|
92
|
+
#
|
|
93
|
+
# *Rows* are a normal, orderable AR relation -- exactly what Pagy's
|
|
94
|
+
# keyset extra is for (see `Pagy::Method`/`pagy(:keyset, ...)`). A
|
|
95
|
+
# worked example, keyset-paginating `Site`, following the same
|
|
96
|
+
# controller shape as an index action anywhere else in the app:
|
|
97
|
+
#
|
|
98
|
+
# class RentalsController < ApplicationController
|
|
99
|
+
# def index
|
|
100
|
+
# @origin = parse_date(params[:origin]) || Date.current.beginning_of_month
|
|
101
|
+
# @dates = (@origin...(@origin + 30)).to_a
|
|
102
|
+
# @pagy, @sites = pagy(:keyset, Site.order(:prefix, :name, :id))
|
|
103
|
+
#
|
|
104
|
+
# respond_to do |format|
|
|
105
|
+
# format.html
|
|
106
|
+
# format.turbo_stream # only reached by the row_pagination frame below
|
|
107
|
+
# end
|
|
108
|
+
# end
|
|
109
|
+
# end
|
|
110
|
+
#
|
|
111
|
+
# # index.html.erb
|
|
112
|
+
# <%= render(AtomicView::Components::GanttComponent.new(
|
|
113
|
+
# id: "site-schedule",
|
|
114
|
+
# dates: @dates,
|
|
115
|
+
# next_rows_path: (@pagy.next && rentals_path(page: @pagy.next, origin: @origin, format: :turbo_stream)),
|
|
116
|
+
# next_dates_path: more_dates_path(origin: @origin, after: @dates.last)
|
|
117
|
+
# )) do |gantt|
|
|
118
|
+
# @sites.each { |site| render_site_row(gantt, site, @origin, @dates) }
|
|
119
|
+
# end %>
|
|
120
|
+
#
|
|
121
|
+
# # index.turbo_stream.erb -- reached when the row_pagination frame's
|
|
122
|
+
# # lazily-loaded `src` fires (see "Loading more rows" below)
|
|
123
|
+
# <%= turbo_stream.append("site-schedule_rows") do %>
|
|
124
|
+
# <% @sites.each { |site| render_site_row(gantt, site, @origin, @dates) } %>
|
|
125
|
+
# <% end %>
|
|
126
|
+
# <%= turbo_stream.replace("site-schedule_row_pagination") do %>
|
|
127
|
+
# <%= tag.turbo_frame(
|
|
128
|
+
# id: "site-schedule_row_pagination",
|
|
129
|
+
# src: (@pagy.next && rentals_path(page: @pagy.next, origin: @origin, format: :turbo_stream)),
|
|
130
|
+
# loading: :lazy
|
|
131
|
+
# ) %>
|
|
132
|
+
# <% end %>
|
|
133
|
+
#
|
|
134
|
+
# `render_site_row` also has to decide *which* bookings to pass to
|
|
135
|
+
# `with_item` -- not every booking a site has, just the ones that fall
|
|
136
|
+
# anywhere within `@dates` (see "New rows outside the currently-loaded
|
|
137
|
+
# date window" below for why):
|
|
138
|
+
#
|
|
139
|
+
# def render_site_row(gantt, site, origin, dates)
|
|
140
|
+
# bookings = site.bookings.select { |b| GanttComponent.overlaps_range?(b.starts_on, b.ends_on, dates.first, dates.last) }
|
|
141
|
+
# gantt.with_row(id: dom_id(site, :row), label: site.code, sublabel: site.park.name, origin: origin) do |row|
|
|
142
|
+
# bookings.each { |b| row.with_item(starts_on: b.starts_on, ends_on: b.ends_on, label: b.camper_name, origin: origin, variant: :success, href: booking_path(b)) }
|
|
143
|
+
# end
|
|
144
|
+
# end
|
|
145
|
+
#
|
|
146
|
+
# *Dates* aren't an AR relation at all -- "the next 30 days" is just date
|
|
147
|
+
# arithmetic, so `next_dates_path` is simpler custom logic rather than
|
|
148
|
+
# Pagy: a `MoreDatesController` (or an action alongside the one above)
|
|
149
|
+
# that takes `after:` (the last date currently loaded) and `origin:`,
|
|
150
|
+
# computes the next batch of `Date`s, and returns a stream that appends
|
|
151
|
+
# both new date header cells *and* any new bars those dates bring into
|
|
152
|
+
# range -- neither one is optional, and this is genuinely two separate
|
|
153
|
+
# append operations, since the header cells and the bars live in
|
|
154
|
+
# different DOM containers (`#{id}_dates` vs. each row's own `track_id`):
|
|
155
|
+
#
|
|
156
|
+
# class MoreDatesController < ApplicationController
|
|
157
|
+
# def show
|
|
158
|
+
# origin = parse_date(params[:origin])
|
|
159
|
+
# after = parse_date(params[:after])
|
|
160
|
+
# new_dates = next_batch_of_dates(after: after) # your own date-arithmetic helper
|
|
161
|
+
# @sites = authorized(Site.all).order(:prefix, :name, :id) # the same rows currently on the page -- see below
|
|
162
|
+
# end
|
|
163
|
+
# end
|
|
164
|
+
#
|
|
165
|
+
# # show.turbo_stream.erb
|
|
166
|
+
# <% new_dates.each do |date| %>
|
|
167
|
+
# <%= turbo_stream.append("site-schedule_dates") do %>
|
|
168
|
+
# <%= render(AtomicView::Components::GanttComponent::DateHeaderComponent.new(date: date, today: Date.current)) %>
|
|
169
|
+
# <% end %>
|
|
170
|
+
# <% end %>
|
|
171
|
+
#
|
|
172
|
+
# <% @sites.each do |site| %>
|
|
173
|
+
# <% bookings = site.bookings.select { |b| AtomicView::Components::GanttComponent.overlaps_range?(b.starts_on, b.ends_on, new_dates.first, new_dates.last) } %>
|
|
174
|
+
# <% bookings.each do |b| %>
|
|
175
|
+
# <%= turbo_stream.append(dom_id(site, :row) + "_track") do %>
|
|
176
|
+
# <%= render(AtomicView::Components::GanttComponent::ItemComponent.new(starts_on: b.starts_on, ends_on: b.ends_on, origin: origin, label: b.camper_name, variant: :success, href: booking_path(b))) %>
|
|
177
|
+
# <% end %>
|
|
178
|
+
# <% end %>
|
|
179
|
+
# <% end %>
|
|
180
|
+
#
|
|
181
|
+
# <%= turbo_stream.replace("site-schedule_date_sentinel") do %>
|
|
182
|
+
# <div id="site-schedule_date_sentinel" data-atomic-view--gantt-target="dateSentinel" data-next-page="<%= more_dates_path(origin: origin, after: new_dates.last) %>"></div>
|
|
183
|
+
# <% end %>
|
|
184
|
+
#
|
|
185
|
+
# That response needs to know which rows are currently rendered (to
|
|
186
|
+
# backfill *their* new bars, not just draw the header) -- either
|
|
187
|
+
# re-derive the same page of rows from `params[:page]`/keyset params
|
|
188
|
+
# round-tripped alongside `after:`, or (simpler, if the row page is
|
|
189
|
+
# small) have the client send the visible row ids and scope the query to
|
|
190
|
+
# just those. `prev_dates_path`'s response is the same shape with every
|
|
191
|
+
# `append` swapped for `prepend`, targeting `#{id}_date_start_trigger`
|
|
192
|
+
# instead.
|
|
193
|
+
#
|
|
194
|
+
# == New rows outside the currently-loaded date window
|
|
195
|
+
#
|
|
196
|
+
# A booking's bar always renders at its real, correctly-computed pixel
|
|
197
|
+
# position -- it is never clipped or hidden by this component. So a site
|
|
198
|
+
# loaded later (via `next_rows_path`) whose booking starts on day 90,
|
|
199
|
+
# when only 30 days are loaded, renders that bar 90 columns from
|
|
200
|
+
# `origin` regardless -- past the last date header cell, in the blank
|
|
201
|
+
# area where the track's gridline background stops (that background,
|
|
202
|
+
# like the header, only ever covers `dates`; it doesn't extend to meet
|
|
203
|
+
# an overflowing bar). It isn't lost: the browser's scroll region grows
|
|
204
|
+
# to include it (out-of-flow descendants still count toward their
|
|
205
|
+
# scrolling ancestor's scrollable area), so scrolling right reveals it
|
|
206
|
+
# sitting in that gap, and it "snaps" into a normal, gridded column the
|
|
207
|
+
# moment `next_dates_path` loads that far.
|
|
208
|
+
#
|
|
209
|
+
# That's a legitimate transient state, not a bug -- but there's rarely a
|
|
210
|
+
# reason to let it happen at all. Scope every row's bookings to ones
|
|
211
|
+
# that actually intersect `dates` (see `render_site_row` above,
|
|
212
|
+
# `.overlaps_range?`) before calling `with_item`, the same way you'd
|
|
213
|
+
# already scope any other index view's records to what's on the current
|
|
214
|
+
# page. A booking that starts beyond the loaded window simply isn't
|
|
215
|
+
# rendered yet; it appears exactly when `next_dates_path` reaches it,
|
|
216
|
+
# via that response's own "backfill every row's new bars" step (see
|
|
217
|
+
# "Loading more days" below) -- which is the same step responsible for
|
|
218
|
+
# backfilling it into *this* row once it exists, no special-casing
|
|
219
|
+
# needed for "a row that was loaded after the date window had already
|
|
220
|
+
# moved."
|
|
221
|
+
#
|
|
222
|
+
# A booking that starts *before* `origin` is the mirror case (e.g. an
|
|
223
|
+
# already-in-progress booking on the very first render) -- its bar's
|
|
224
|
+
# `left` is negative from the start, extending into scroll space that
|
|
225
|
+
# hasn't been unlocked by any `prev_dates_path` prepend yet. That's why
|
|
226
|
+
# `ItemComponent`'s label is `position: sticky` rather than flowing at
|
|
227
|
+
# the bar's own true start: without it, the label would sit somewhere
|
|
228
|
+
# the user can't scroll to (yet), rendering the bar with no visible text
|
|
229
|
+
# at all until they've scrolled back far enough to reach it.
|
|
230
|
+
#
|
|
231
|
+
# == Turbo Stream contract
|
|
232
|
+
#
|
|
233
|
+
# === Loading more rows
|
|
234
|
+
#
|
|
235
|
+
# `next_rows_path` is rendered as a lazily-loaded `<turbo-frame
|
|
236
|
+
# loading="lazy">` (`#{id}_row_pagination`, see `row_pagination_id`) --
|
|
237
|
+
# the same "infinite frame" pattern as any other keyset-paginated index
|
|
238
|
+
# page in a Turbo app: no custom JS, the frame's own `loading="lazy"`
|
|
239
|
+
# fetches `src` once it scrolls into the *page* viewport. That fetch's
|
|
240
|
+
# response is a turbo_stream (the frame's `src` should carry
|
|
241
|
+
# `format: :turbo_stream`, same as the example above) that:
|
|
242
|
+
# 1. `append`s new `RowComponent`s to `#{id}_rows`
|
|
243
|
+
# 2. `replace`s `#{id}_row_pagination` with an updated frame (new
|
|
244
|
+
# `src`) to continue the chain, or an empty one (no `src`) when
|
|
245
|
+
# there are no more rows
|
|
246
|
+
#
|
|
247
|
+
# === Loading more days, in either direction
|
|
248
|
+
#
|
|
249
|
+
# Both directions are handled by a small Stimulus controller
|
|
250
|
+
# (`atomic-view--gantt`), but deliberately *not* the same way:
|
|
251
|
+
#
|
|
252
|
+
# - `next_dates_path` (scrolling right) is watched by a trailing
|
|
253
|
+
# sentinel (`#{id}_date_sentinel`) via IntersectionObserver, rooted
|
|
254
|
+
# at the grid's own horizontally-scrolling container rather than the
|
|
255
|
+
# page viewport (a plain `loading="lazy"` frame can't do that -- it
|
|
256
|
+
# only ever watches the page viewport).
|
|
257
|
+
# - `prev_dates_path` (scrolling left) is loaded by a plain button the
|
|
258
|
+
# user clicks (`#{id}_date_start_trigger`), not auto-loaded on
|
|
259
|
+
# scroll -- see the Stimulus controller's own docs for why an
|
|
260
|
+
# IntersectionObserver-driven sentinel doesn't work well for this
|
|
261
|
+
# direction (in short: it sits right where the grid is already
|
|
262
|
+
# scrolled to on a normal page load, so there's no clean way to tell
|
|
263
|
+
# "the user scrolled here" apart from "this is just where it starts").
|
|
264
|
+
#
|
|
265
|
+
# Both fetch the same way, with `Accept: text/vnd.turbo-stream.html`.
|
|
266
|
+
#
|
|
267
|
+
# A `next_dates_path` response must:
|
|
268
|
+
# 1. `append`s new `DateHeaderComponent` cells to `#{id}_dates`
|
|
269
|
+
# 2. for every row *currently on the page*, `append`s any new
|
|
270
|
+
# `ItemComponent` bars that fall in the newly-loaded date range to
|
|
271
|
+
# that row's `RowComponent#track_id`
|
|
272
|
+
# 3. `replace`s `#{id}_date_sentinel` with an updated one, or `remove`s
|
|
273
|
+
# it when there are no more days ahead
|
|
274
|
+
# 4. `append`s one or more `MonthBandComponent` segments to
|
|
275
|
+
# `#{id}_month_band`, covering the same newly-loaded dates -- see
|
|
276
|
+
# that class's docs, "Loading more days"
|
|
277
|
+
#
|
|
278
|
+
# A `prev_dates_path` response is the mirror image -- `prepend` instead
|
|
279
|
+
# of `append` to `#{id}_dates`/`#{id}_month_band` and each row's track
|
|
280
|
+
# (bars are absolutely positioned, so prepend vs. append only matters for
|
|
281
|
+
# the date header cells' visual order), and it `replace`s
|
|
282
|
+
# `#{id}_date_start_trigger` with an updated one (new `data-prev-page`),
|
|
283
|
+
# or `remove`s it when there are no earlier days behind. After that
|
|
284
|
+
# stream renders, the controller adds the newly-prepended width to the
|
|
285
|
+
# scroller's `scrollLeft` so the content the user was already looking at
|
|
286
|
+
# doesn't visually jump -- the same scroll-anchor correction any "load
|
|
287
|
+
# older messages above" infinite-scroll UI needs -- and rebases every
|
|
288
|
+
# bar/today strip already on the page by that same amount (see
|
|
289
|
+
# "`origin:` has to round-trip through every request" above).
|
|
290
|
+
#
|
|
291
|
+
# See "New rows outside the currently-loaded date window" above for what
|
|
292
|
+
# happens when a bar's dates fall outside what `next_dates_path`/
|
|
293
|
+
# `prev_dates_path` have loaded so far -- a real, if usually avoidable,
|
|
294
|
+
# possibility on this axis too (a newly-appended row's bookings, or a
|
|
295
|
+
# newly-appended bar from a horizontal-load response, same idea either
|
|
296
|
+
# way).
|
|
297
|
+
#
|
|
298
|
+
# Rows themselves only paginate forward (down) -- `next_rows_path` has
|
|
299
|
+
# no `prev_rows_path` counterpart. Add one yourself (a second lazily-
|
|
300
|
+
# loaded frame above `#{id}_rows`, prepending instead of appending) if
|
|
301
|
+
# you need it; nothing here assumes rows can't also load backward, there
|
|
302
|
+
# just isn't a use case yet that needs it.
|
|
303
|
+
class GanttComponent < AtomicView::Component
|
|
304
|
+
DEFAULT_CELL_WIDTH = 42
|
|
305
|
+
DEFAULT_LABEL_WIDTH = 176
|
|
306
|
+
|
|
307
|
+
# Bar height, the gap below it before the next lane, and the gap above
|
|
308
|
+
# the first lane -- see `ItemComponent#lane`/`RowComponent#lanes` and
|
|
309
|
+
# `.pack_lanes` for the overlap-handling these size.
|
|
310
|
+
BAR_HEIGHT = 28
|
|
311
|
+
LANE_GAP = 8
|
|
312
|
+
LANE_TOP_PADDING = 8
|
|
313
|
+
LANE_HEIGHT = BAR_HEIGHT + LANE_GAP
|
|
314
|
+
|
|
315
|
+
renders_many :rows, "AtomicView::Components::GanttComponent::RowComponent"
|
|
316
|
+
|
|
317
|
+
attr_reader :id, :dates, :next_rows_path, :next_dates_path, :prev_dates_path, :cell_width, :label_width,
|
|
318
|
+
:today, :date_root_margin, :empty_message
|
|
319
|
+
|
|
320
|
+
# @param id [String] unique DOM id for this grid; every other id
|
|
321
|
+
# (rows/dates containers, sentinels, frames) is derived from it.
|
|
322
|
+
# @param dates [Array<Date>] the date columns to render *this call* --
|
|
323
|
+
# for the initial render, the first page of the visible range; a
|
|
324
|
+
# `next_dates_path`/`prev_dates_path` response renders only the
|
|
325
|
+
# newly-appended/prepended dates.
|
|
326
|
+
# @param next_rows_path [String, nil] URL for the next page of rows,
|
|
327
|
+
# rendered as a lazily-loaded turbo-frame. Omit (nil) when there are
|
|
328
|
+
# no more rows to load.
|
|
329
|
+
# @param next_dates_path [String, nil] URL for the next page of date
|
|
330
|
+
# columns, loaded when the grid scrolls right. Omit when there are no
|
|
331
|
+
# more days ahead to load.
|
|
332
|
+
# @param prev_dates_path [String, nil] URL for the previous page of
|
|
333
|
+
# date columns, loaded when the grid scrolls left. Omit when there
|
|
334
|
+
# are no earlier days to load.
|
|
335
|
+
# @param cell_width [Integer] pixel width of one day column. Must match
|
|
336
|
+
# whatever `RowComponent`/`ItemComponent` instances use for this same
|
|
337
|
+
# grid (they default to the same constant, so leave this alone unless
|
|
338
|
+
# you're overriding it everywhere).
|
|
339
|
+
# @param label_width [Integer] pixel width of the sticky label column.
|
|
340
|
+
# @param today [Date, nil] highlights the column `today` falls within.
|
|
341
|
+
# @param date_root_margin [String] IntersectionObserver rootMargin for
|
|
342
|
+
# the trailing (right/`next_dates_path`) date sentinel, rooted at the
|
|
343
|
+
# grid's own scroll container rather than the viewport. Only the
|
|
344
|
+
# forward direction uses an observer -- see "Loading more days, in
|
|
345
|
+
# either direction" above for why the backward one is a plain button
|
|
346
|
+
# instead.
|
|
347
|
+
# @param empty_message [String] shown when there are no rows at all.
|
|
348
|
+
def initialize(
|
|
349
|
+
id:,
|
|
350
|
+
dates:,
|
|
351
|
+
next_rows_path: nil,
|
|
352
|
+
next_dates_path: nil,
|
|
353
|
+
prev_dates_path: nil,
|
|
354
|
+
cell_width: DEFAULT_CELL_WIDTH,
|
|
355
|
+
label_width: DEFAULT_LABEL_WIDTH,
|
|
356
|
+
today: nil,
|
|
357
|
+
date_root_margin: "0px 400px 0px 0px",
|
|
358
|
+
empty_message: "Nothing scheduled.",
|
|
359
|
+
**options
|
|
360
|
+
)
|
|
361
|
+
super()
|
|
362
|
+
@id = id
|
|
363
|
+
@dates = dates
|
|
364
|
+
@next_rows_path = next_rows_path
|
|
365
|
+
@next_dates_path = next_dates_path
|
|
366
|
+
@prev_dates_path = prev_dates_path
|
|
367
|
+
@cell_width = cell_width
|
|
368
|
+
@label_width = label_width
|
|
369
|
+
@today = today
|
|
370
|
+
@date_root_margin = date_root_margin
|
|
371
|
+
@empty_message = empty_message
|
|
372
|
+
@options = options
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Absolute position of `date`, in days. Epoch-independent (only ever
|
|
376
|
+
# used as a difference between two calls, in `.offset_px`/`.span_px`
|
|
377
|
+
# below).
|
|
378
|
+
def self.position(date)
|
|
379
|
+
date.to_date.jd
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
# Whether `starts_on`..`ends_on` shares any inclusive day with
|
|
383
|
+
# `range_starts_on`..`range_ends_on`. Meant for scoping which bookings
|
|
384
|
+
# to render at all -- e.g. `site.bookings.select { |b|
|
|
385
|
+
# GanttComponent.overlaps_range?(b.starts_on, b.ends_on, dates.first,
|
|
386
|
+
# dates.last) }` before calling `with_item` -- so a row never carries
|
|
387
|
+
# bars far outside what's currently loaded; see "New rows outside the
|
|
388
|
+
# currently-loaded date window" above.
|
|
389
|
+
def self.overlaps_range?(starts_on, ends_on, range_starts_on, range_ends_on)
|
|
390
|
+
starts_on.to_date <= range_ends_on.to_date && ends_on.to_date >= range_starts_on.to_date
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Pixel offset of `date` from `origin` -- the left edge of a bar/strip
|
|
394
|
+
# starting on `date`. Negative when `date` is before `origin` (a bar
|
|
395
|
+
# that started before the currently-loaded window); callers don't need
|
|
396
|
+
# to clip this themselves, it just renders (partially) off the
|
|
397
|
+
# scroller's left edge.
|
|
398
|
+
def self.offset_px(date, origin:, cell_width: DEFAULT_CELL_WIDTH)
|
|
399
|
+
(position(date) - position(origin)) * cell_width
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# Pixel width of a bar spanning `starts_on`..`ends_on`, inclusive of
|
|
403
|
+
# both end dates.
|
|
404
|
+
def self.span_px(starts_on, ends_on, cell_width: DEFAULT_CELL_WIDTH)
|
|
405
|
+
(position(ends_on.to_date + 1) - position(starts_on)) * cell_width
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
# Greedily assigns each `[starts_on, ends_on]` pair in `ranges` to the
|
|
409
|
+
# lowest-numbered lane that doesn't overlap anything already placed
|
|
410
|
+
# there, returning the lanes as an `Array<Integer>` in the same order
|
|
411
|
+
# as `ranges` (not sort order -- `ranges` doesn't need to be
|
|
412
|
+
# pre-sorted). Two ranges "overlap" if they share any inclusive day;
|
|
413
|
+
# adjacent ranges (one ending the day before the next starts) can share
|
|
414
|
+
# a lane. See `ItemComponent#lane`/`RowComponent#lanes` for how to use
|
|
415
|
+
# the result.
|
|
416
|
+
#
|
|
417
|
+
# GanttComponent.pack_lanes([[Date.new(2026, 9, 1), Date.new(2026, 9, 5)], [Date.new(2026, 9, 3), Date.new(2026, 9, 8)], [Date.new(2026, 9, 10), Date.new(2026, 9, 12)]])
|
|
418
|
+
# # => [0, 1, 0] -- the first two overlap (Sep 3-5) so need separate lanes;
|
|
419
|
+
# # the third starts after the first one ends, so reuses lane 0.
|
|
420
|
+
def self.pack_lanes(ranges)
|
|
421
|
+
lane_ends = []
|
|
422
|
+
lanes = Array.new(ranges.size)
|
|
423
|
+
|
|
424
|
+
ranges.each_with_index.sort_by { |(starts_on, _ends_on), _index| starts_on.to_date }.each do |(starts_on, ends_on), index|
|
|
425
|
+
starts_on = starts_on.to_date
|
|
426
|
+
ends_on = ends_on.to_date
|
|
427
|
+
lane = lane_ends.index { |lane_end| starts_on > lane_end } || lane_ends.length
|
|
428
|
+
lane_ends[lane] = ends_on
|
|
429
|
+
lanes[index] = lane
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
lanes
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# One entry per distinct calendar month present in `dates`, clipped to
|
|
436
|
+
# whatever's currently loaded -- e.g. `dates` starting mid-September
|
|
437
|
+
# yields a first segment covering just `dates.first`..(Sep 30), not the
|
|
438
|
+
# 1st..30th (`count` is the number of *loaded* days, not the month's
|
|
439
|
+
# real length). `count` is exactly what `MonthBandComponent#span`
|
|
440
|
+
# wants.
|
|
441
|
+
def month_segments
|
|
442
|
+
dates.chunk { |date| date.beginning_of_month }.map do |month_start, days|
|
|
443
|
+
{month_start: month_start, count: days.size}
|
|
444
|
+
end
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
def scroller_id = "#{id}_scroller"
|
|
448
|
+
def month_band_id = "#{id}_month_band"
|
|
449
|
+
def dates_id = "#{id}_dates"
|
|
450
|
+
def rows_id = "#{id}_rows"
|
|
451
|
+
def row_pagination_id = "#{id}_row_pagination"
|
|
452
|
+
def date_sentinel_id = "#{id}_date_sentinel"
|
|
453
|
+
def date_loader_id = "#{id}_date_loader"
|
|
454
|
+
def date_start_trigger_id = "#{id}_date_start_trigger"
|
|
455
|
+
def date_start_loader_id = "#{id}_date_start_loader"
|
|
456
|
+
|
|
457
|
+
def paginated_rows? = next_rows_path.present?
|
|
458
|
+
def paginated_dates_forward? = next_dates_path.present?
|
|
459
|
+
def paginated_dates_backward? = prev_dates_path.present?
|
|
460
|
+
def empty? = rows.empty?
|
|
461
|
+
|
|
462
|
+
# Shared by the date header and month band containers -- both are a
|
|
463
|
+
# single-row CSS Grid (`grid-auto-flow: column`) so their children
|
|
464
|
+
# (`DateHeaderComponent` cells, `MonthBandComponent` segments) never
|
|
465
|
+
# need a pixel width of their own. See "How the grid is laid out"
|
|
466
|
+
# above.
|
|
467
|
+
def column_grid_style = "grid-auto-columns: #{cell_width}px"
|
|
468
|
+
|
|
469
|
+
def html_options
|
|
470
|
+
@options.except(:class, :data)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def html_class
|
|
474
|
+
class_names("rounded-card border border-border bg-surface", @options[:class])
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
def data_attributes
|
|
478
|
+
controllers = ["atomic-view--gantt", @options.dig(:data, :controller)].compact.join(" ")
|
|
479
|
+
(@options[:data] || {}).except(:controller).merge(
|
|
480
|
+
"controller" => controllers,
|
|
481
|
+
"atomic-view--gantt-date-root-margin-value" => date_root_margin
|
|
482
|
+
)
|
|
483
|
+
end
|
|
484
|
+
end
|
|
485
|
+
end
|
|
486
|
+
end
|
data/lib/atomic_view/version.rb
CHANGED