atomic_view 0.1.18 → 0.2.1

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.
@@ -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
@@ -34,8 +34,9 @@ module AtomicView
34
34
  class TimelineComponent < AtomicView::Component
35
35
  renders_many :items, "ItemComponent"
36
36
 
37
- def initialize(**options)
37
+ def initialize(continues: false, **options)
38
38
  super()
39
+ @continues = continues
39
40
  @options = options
40
41
  end
41
42
 
@@ -43,12 +44,15 @@ module AtomicView
43
44
  class_names("flex flex-col", @options[:class])
44
45
  end
45
46
 
47
+ # `continues:` says this render is one page of a longer feed -- the connector
48
+ # keeps going past the last item, into whatever the next turbo frame appends
49
+ # or the stream prepends above.
46
50
  def item_wrapper_class(index)
47
- class_names("relative flex gap-3.5", "pb-6" => !last?(index))
51
+ class_names("relative flex gap-3.5", "pb-6" => !last?(index) || @continues)
48
52
  end
49
53
 
50
54
  def render_line?(index)
51
- !last?(index)
55
+ !last?(index) || @continues
52
56
  end
53
57
 
54
58
  private