recourse 5.3.0 → 5.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0c224b5ff48a88afa37e70da5d2157d9b5229eccdc02d7d9f8ce1b85e4df31d8
4
- data.tar.gz: 50da170bddd967baba9c46c276e61f750694465a2c2e11c5fe09c5e59cc84eff
3
+ metadata.gz: fcfda63efd2c2320ea4e97b5805d1ba4aadeb0686b78d613c2b2c4ad71b9fc17
4
+ data.tar.gz: ffaa33863a46cdf213c5ab2fab3266b7266bf4e73073e88be42928e72838471f
5
5
  SHA512:
6
- metadata.gz: 91bc98cadd2186f29b342da8804c0926f523656aec2149e8025393c4c3ba3ec034e1ae359732cb81368192ac00b70f484731640227692c18b4c5d1ed09adac1f
7
- data.tar.gz: a0565fb334f552e1a403af4db94f8d878f25ae5473e6af167304785fc017d42e392e9276ece5b2fcd4475fe48511cac8216a670adc23f8269f9ee8b931573c4f
6
+ metadata.gz: 45bfd3ce50837e3a0d28a02f226f0eca777ebb2b25e880059db61052313a67ebd311b8c2404f0281b147713fa346d8cf3653effb9f180ba0743d998acba12a10
7
+ data.tar.gz: 20f922caf3230244b441b47dcfd3fa4ca7fe7abd5045c1f765f47cad8ce54d737be6aabf22dce13c0d81720e15c1cc7803b9b8077df9fcbfda90ac5a9a4fd311
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## 5.4.0 - 2026-09-17
11
+
12
+ * [Feature] A table of events can be read as a week
13
+
14
+ The second shape a page of rows can take, beside the map: a model keeping a `starts_at`
15
+ and an `ends_at` offers `Display as calendar` in its footer, and `/shifts.cal` draws
16
+ them as a week — a column a day from Sunday, the hours that week's own rows cover down
17
+ the side, and each row placed by the share of the day it takes, in a lane of its own
18
+ where a day's rows overlap, saying what it is, whoever it points at and the hours
19
+ themselves. The grid is as tall as a table's own first page, so the two shapes carry
20
+ their footers at one height. A week rather than a page is the unit, so `?week=` moves it
21
+ and `<<`, `<`, `>` and `>>` stand where a table's pages stand — a week either side and
22
+ four weeks beyond each — with the week named between them;
23
+ the search, the filters and the reader's own time zone all travel with it. A search or
24
+ a filter now answers in the shape it was picked in rather than falling back to the
25
+ table, and `Display as map` and `Display as table` are drawn by one helper, so a model
26
+ earning both shapes offers both.
27
+
10
28
  ## 5.3.0 - 2026-09-16
11
29
 
12
30
  * [Feature] A record's files are a field, a value and a page
data/README.md CHANGED
@@ -208,6 +208,37 @@ Security Policy allows `maps.googleapis.com` for scripts and connections, and Go
208
208
  tile hosts for images.
209
209
 
210
210
 
211
+ ### Calendars
212
+
213
+ A table whose model keeps a `starts_at` and an `ends_at` can be read as a week of the same
214
+ rows: the footer offers `Display as calendar`, and `/shifts.cal` draws a column a day with
215
+ each row placed by the hours it runs between — rows that overlap in lanes of their own.
216
+ A row says what it is, whoever it points at, and the hours themselves, each led to its own
217
+ page where the routes drew one.
218
+
219
+ ```ruby
220
+ recourses :shifts
221
+ ```
222
+
223
+ A week rather than a page is what a calendar shows, so there is nothing to paginate:
224
+ `?week=2026-09-13` moves to the week holding that day, and `<<`, `<`, `>` and `>>` stand
225
+ where the pages stand under a table — a week either side and four weeks beyond each —
226
+ with the week named between them. The search and the
227
+ filters travel with it — and answer in the shape they were picked in — so a calendar
228
+ narrowed to one person stays a narrowed calendar as the weeks move.
229
+
230
+ The grid stands as tall as a table's own first page, so the weeks under a calendar sit
231
+ where the pages under a table do. The week opens on Sunday and the hours are the reader's
232
+ own — the same cookie every other
233
+ time on these pages is drawn against — and the scale runs only over the hours that week's
234
+ rows cover, or a working day where it holds none. A row running past midnight is drawn on
235
+ the day it opens, down to the foot of its column.
236
+
237
+ Both columns have to be datetimes, asked through `type_for_attribute`, so an
238
+ `attribute :starts_at, :datetime` override counts and a column of another kind named
239
+ `starts_at` earns nothing.
240
+
241
+
211
242
  ## Development
212
243
 
213
244
  ```bash
@@ -0,0 +1,40 @@
1
+ module Recourse
2
+ # Which week a calendar draws, and the rows that fall inside it.
3
+ module Weeks
4
+ # How a week is written into an address: `?week=2026-09-13`, any day of the one
5
+ # meant, since a reader following a link never types one.
6
+ WEEK_FORMAT = '%Y-%m-%d'
7
+
8
+ private
9
+
10
+ # The Sunday the calendar opens on: the week the address names, or the reader's
11
+ # own. An address is a stranger's to write, so a day nothing can be made of is
12
+ # this week rather than a 500.
13
+ def recourse_week
14
+ Recourse.week_of Date.strptime(params[:week].to_s, WEEK_FORMAT)
15
+ rescue Date::Error
16
+ Recourse.week_of Time.zone.today
17
+ end
18
+
19
+ # What the index lists, as the pair it assigns: one week where the calendar was
20
+ # asked for, and one page everywhere else. A calendar has no pages to take and a
21
+ # table has no week, so neither shape carries the other's.
22
+ def week_or_page(scope)
23
+ return pagy scope, limit: recourse_limit unless request.format.cal?
24
+
25
+ [nil, week_resources(scope)]
26
+ end
27
+
28
+ # The week's rows, earliest first and all of them: a week is how much a calendar
29
+ # shows, so the sort a heading asked for is not what a grid reads by. Against the
30
+ # reader's own clock, which `Zoning` has already put in `Time.zone` — the same
31
+ # midnight the grid draws from.
32
+ def week_resources(scope)
33
+ @recourse_week = recourse_week
34
+ opening = @recourse_week.in_time_zone
35
+
36
+ scope.reorder(Recourse::START_COLUMN => :asc)
37
+ .where Recourse::START_COLUMN => opening...(opening + 1.week)
38
+ end
39
+ end
40
+ end
@@ -6,7 +6,7 @@ module Recourse
6
6
  include Pagy::Method, AttachmentResolution, AttachmentWriting,
7
7
  Landing, Paging, ListResolution, ParentNaming,
8
8
  ParentResolution, ReferenceResolution, ResourceResolution,
9
- Zoning
9
+ Weeks, Zoning
10
10
 
11
11
  helper Helpers
12
12
 
@@ -19,13 +19,17 @@ module Recourse
19
19
  # The model broadcasts refreshes for its index, before `create` commits its own.
20
20
  before_action :broadcast_resource_changes
21
21
 
22
- # Lists one page of the model the route is named after. `@q` is Ransack's own name.
22
+ # Lists one page of the model the route is named after, or one week of it where the
23
+ # calendar was asked for — a week is how much that shape shows. `@q` is Ransack's own.
23
24
  def index
24
25
  search = Search.new recourse_relation, params[:q]
25
26
  @q = search.query
26
- @pagy, @resources = pagy search.scope, limit: recourse_limit
27
- # A map is the same page in another shape, so the HTML templates draw it.
28
- render :index, formats: :html if request.format.map?
27
+
28
+ @pagy, @resources = week_or_page search.scope
29
+
30
+ # A map and a calendar are the same page in another shape: the HTML templates draw
31
+ # both.
32
+ render :index, formats: :html if SHAPES.include? request.format.symbol
29
33
  end
30
34
 
31
35
  # Builds a blank record under the name Rails would use: @contact for contacts, with
@@ -17,15 +17,16 @@
17
17
  <%= yield :head %>
18
18
 
19
19
  <%# Everything the page is styled by, from the one place every HouseAccount app draws
20
- on: Bootstrap 6, its icons and the house's own rules for this gem's markup. Pinned
21
- to a version, so a change there reaches these pages when somebody moves the pin
22
- here and not before; the same version stands in the script below and in
23
- `Recourse::THEMES_PATH`. Two connections opened before the parser finds either tag:
24
- the stylesheet's plain one and the CORS one a module script and a font are fetched
25
- over. -%>
26
- <link rel='preconnect' href='https://design.houseaccount.com'>
27
- <link rel='preconnect' href='https://design.houseaccount.com' crossorigin>
28
- <link rel='stylesheet' href='https://design.houseaccount.com/v0.5.0/css/houseaccount.css'>
20
+ on: Bootstrap 6, its icons and the house's own rules for this gem's markup. Served
21
+ from the `houseaccount` package on a CDN, pinned to a version, so a change there
22
+ reaches these pages when somebody moves the pin here and not before; the same
23
+ version stands in the script below and in `Recourse::THEMES_PATH`. A published
24
+ version is never written again, so the bytes behind that URL cannot move under us.
25
+ Two connections opened before the parser finds either tag: the stylesheet's plain
26
+ one and the CORS one a module script and a font are fetched over. -%>
27
+ <link rel='preconnect' href='https://cdn.jsdelivr.net'>
28
+ <link rel='preconnect' href='https://cdn.jsdelivr.net' crossorigin>
29
+ <link rel='stylesheet' href='https://cdn.jsdelivr.net/npm/houseaccount@0.11.0/public/css/houseaccount.css'>
29
30
  <%# The one thing these screens set that the house's stylesheet does not: system faces
30
31
  at a compact size, for pages dense with figures. 14px as rem, so a browser's own
31
32
  text-size setting still scales it. After the stylesheet, since both are `:root`. -%>
@@ -49,7 +50,7 @@
49
50
  <%# Turbo, Bootstrap and a Stimulus application with every controller this markup
50
51
  names, started once under `window.Stimulus`. In the head, so a controller exists
51
52
  before the body's first `data-controller`. -%>
52
- <script type='module' src='https://design.houseaccount.com/v0.5.0/js/houseaccount.js'></script>
53
+ <script type='module' src='https://cdn.jsdelivr.net/npm/houseaccount@0.11.0/public/js/houseaccount.js'></script>
53
54
  </head>
54
55
 
55
56
  <%# The reader's zone is told to the server here rather than from a page of its
@@ -0,0 +1,40 @@
1
+ <%# locals: (recourses:, week:) %>
2
+ <div class='card overflow-hidden mb-3'>
3
+ <div class='overflow-x-auto'>
4
+ <%# One grid rather than a row of columns: the days have to line up under their own
5
+ headings, and a column whose width is what it happens to hold does not. The
6
+ gutter is as wide as `12am` reads, and the seven take equal shares of the rest.
7
+ A minimum width so a phone scrolls the week rather than crushing it. -%>
8
+ <div class='d-grid' style='grid-template-columns: 4rem repeat(7, 1fr); min-width: 48rem'>
9
+ <div class='border-bottom'></div>
10
+
11
+ <% calendar_days(week).each do |day| %>
12
+ <div class='border-bottom border-start text-center py-2'><%= calendar_day_title day %></div>
13
+ <% end %>
14
+
15
+ <%# Every column is the same height whatever hours it draws — a table's own first
16
+ page — and the bands share it out, so the week nav under the grid stands where
17
+ a table's pages stand. -%>
18
+ <div class='d-flex flex-column' style='<%= calendar_grid_style %>'>
19
+ <% calendar_hours(recourses).each do |hour| %>
20
+ <div class='small fg-2 text-end pe-2 flex-fill'><%= calendar_hour_title hour %></div>
21
+ <% end %>
22
+ </div>
23
+
24
+ <%# Each day is those same bands with its own rows laid over them, which is what
25
+ `position-relative` is here for: a row is placed by the share of the grid it
26
+ takes rather than by anything the flow could work out. -%>
27
+ <% calendar_days(week).each do |day| %>
28
+ <div class='d-flex flex-column border-start position-relative' style='<%= calendar_grid_style %>'>
29
+ <% calendar_hours(recourses).each do %>
30
+ <div class='border-top flex-fill'></div>
31
+ <% end %>
32
+
33
+ <%= calendar_events recourses, day, calendar_hours(recourses) %>
34
+ </div>
35
+ <% end %>
36
+ </div>
37
+ </div>
38
+ </div>
39
+
40
+ <%= render 'week', recourses: recourses, week: week %>
@@ -18,11 +18,12 @@
18
18
  <% else %>
19
19
  <%= t 'recourse.displaying', count: pagy.count %>
20
20
  <% end %>
21
- <%# And the other shape the same rows can take, where the model keeps a place or
22
- a point: a table becomes a map of this page's rows, and a map a table again. -%>
23
- <% if (other = view_link) %>
21
+ <%# And every other shape the same rows can take, where the model's own columns
22
+ earn one: a place or a point makes a map of this page's rows, the two ends of
23
+ an event make a week of them, and either leads back to the table. -%>
24
+ <% view_links.each do |link| %>
24
25
  <span class='fg-2'>&middot;</span>
25
- <%= other %>
26
+ <%= link %>
26
27
  <% end %>
27
28
  </div>
28
29
  <%# Centered on the row of their own a phone gives them, and at the end of the shared row from `sm` up. -%>
@@ -1,8 +1,12 @@
1
- <%# locals: (resources:, pagy:) %>
1
+ <%# locals: (resources:, pagy:, week:) %>
2
2
  <%# Both branches, so a search that matches nothing still answers with the frame
3
- it was asked for. `advance` puts the query in the address bar. -%>
3
+ it was asked for. `advance` puts the query in the address bar. A calendar is the
4
+ exception to the empty one: a week with nothing in it is still a week to draw, or
5
+ there would be nothing to reach the weeks either side by. -%>
4
6
  <turbo-frame id='results' data-turbo-action='advance'>
5
- <% if resources.blank? %>
7
+ <% if calendar_view? %>
8
+ <%= render 'calendar', recourses: resources, week: week %>
9
+ <% elsif resources.blank? %>
6
10
  <%= render 'none' %>
7
11
  <% else %>
8
12
  <%= render map_view? ? 'map' : 'table', recourses: resources, pagy: pagy %>
@@ -0,0 +1,19 @@
1
+ <%# locals: (recourses:, week:) %>
2
+ <%# What the footer is to a table: how much is showing, the other shapes the same rows
3
+ take after it, and at the end of the row — where a table's pages stand, and drawn as
4
+ they are — the weeks either side with this one named between them. A week is how much
5
+ a calendar shows, so there is no page size to offer. -%>
6
+ <div class='d-flex flex-column sm:flex-row sm:align-items-center'>
7
+ <div class='ps-2 flex-grow-1 text-center sm:text-start'>
8
+ <%= t 'recourse.displaying', count: recourses.size %>
9
+
10
+ <% view_links.each do |link| %>
11
+ <span class='fg-2'>&middot;</span>
12
+ <%= link %>
13
+ <% end %>
14
+ </div>
15
+
16
+ <div class='px-2 mt-2 sm:mt-0 d-flex justify-content-center sm:justify-content-end'>
17
+ <ul class='pagination mb-0'><%= week_links week %></ul>
18
+ </div>
19
+ </div>
@@ -26,8 +26,8 @@
26
26
  card, beside the record's other pages — its tab, the count, the current one. -%>
27
27
  <% if resource_parent %>
28
28
  <%= render layout: 'card', locals: { record: resource_parent } do %>
29
- <%= render 'results', resources: @resources, pagy: @pagy %>
29
+ <%= render 'results', resources: @resources, pagy: @pagy, week: @recourse_week %>
30
30
  <% end %>
31
31
  <% else %>
32
- <%= render 'results', resources: @resources, pagy: @pagy %>
32
+ <%= render 'results', resources: @resources, pagy: @pagy, week: @recourse_week %>
33
33
  <% end %>
@@ -3,14 +3,22 @@ en:
3
3
  date:
4
4
  formats:
5
5
  recourse: "%b %-d, %Y"
6
+ # A calendar's own two: the day over one of its columns, `Sun, Aug 30`, and the
7
+ # month a week is named by, whose day is an ordinal Ruby writes, not a format.
8
+ recourse_day: "%a, %b %-d"
9
+ recourse_month: "%B"
6
10
  time:
7
11
  formats:
8
12
  recourse: "%b %-d at %I:%M%P %Z"
13
+ # The scale down a calendar, `9am`, and the hours one row runs between, `9:30am`.
14
+ recourse_hour: "%-I%P"
15
+ recourse_time: "%-I:%M%P"
9
16
  recourse:
10
17
  add: Add %{model}
11
18
  ago: "%{distance} ago"
12
19
  all: All %{models}
13
20
  all_values: All
21
+ as_calendar: Display as calendar
14
22
  as_map: Display as map
15
23
  as_table: Display as table
16
24
  # One message in four spellings: what a record is holding, under the field that
@@ -58,11 +66,15 @@ en:
58
66
  more: "%{first} + %{count} more"
59
67
  new: New %{model}
60
68
  new_tab: Open in a new tab
69
+ next_week: Next week
70
+ next_weeks: Next four weeks
61
71
  no_results: No results found
62
72
  none: "No %{models}."
63
73
  optional: Optional
64
74
  per_page: "%{limit} per page"
65
75
  preview: Preview
76
+ previous_week: Previous week
77
+ previous_weeks: Previous four weeks
66
78
  ambiguous: "matches more than one record, so it does not say which: %{label}"
67
79
  reference: "%{model} %{attribute}"
68
80
  reveal: Show
@@ -79,6 +91,8 @@ en:
79
91
  updated: "%{record} was updated."
80
92
  updated_error: "%{model} could not be updated."
81
93
  unset: None
94
+ # The two days a calendar's week runs between, and the year both are in.
95
+ week: "%{from} – %{to}, %{year}"
82
96
  deletion:
83
97
  delete: Delete
84
98
  title: Delete %{record}?
@@ -0,0 +1,27 @@
1
+ # Reopened for the calendar a table of events can be read as.
2
+ module Recourse
3
+ # The column an event opens at, which is the day and the hour a calendar draws it on.
4
+ START_COLUMN = 'starts_at'
5
+
6
+ # And the one it closes at, which is how far down the day it reaches.
7
+ FINISH_COLUMN = 'ends_at'
8
+
9
+ # The two together, which is what a model is asked for its rows to be a week's events.
10
+ EVENT_COLUMNS = [START_COLUMN, FINISH_COLUMN].freeze
11
+
12
+ # The day a week opens on. Sunday, which is the column a calendar leads with wherever
13
+ # these pages are read — `Date.beginning_of_week` is the host's own setting, and a
14
+ # grid of seven is not a thing to draw two ways.
15
+ WEEK_START = :sunday
16
+
17
+ # Whether a model's rows can be drawn on a calendar: both ends of an event, and both
18
+ # an instant rather than a day — a date has no hour for a week to place it by. Asked
19
+ # of the type the model reports, so an `attribute` override counts and a column of
20
+ # another kind named `starts_at` earns nothing.
21
+ def self.calendarable?(model)
22
+ EVENT_COLUMNS.all? { |column| model.type_for_attribute(column).type == :datetime }
23
+ end
24
+
25
+ # The Sunday a day belongs to, which is the week a calendar of it opens on.
26
+ def self.week_of(day) = day.beginning_of_week WEEK_START
27
+ end
@@ -15,10 +15,11 @@ module Recourse
15
15
  ActionDispatch::Routing::Mapper.include Scopes, Routes
16
16
  end
17
17
 
18
- # `/counties.map` is the same page in another shape, so the format is a name for
19
- # HTML: without it Rails has no type for the extension and answers 406.
18
+ # `/counties.map` and `/shifts.cal` are the same page in another shape, so each
19
+ # format is a name for HTML: without one Rails has no type for the extension and
20
+ # answers 406.
20
21
  initializer 'recourse.formats' do
21
- Mime::Type.register_alias 'text/html', :map
22
+ SHAPES.each { |shape| Mime::Type.register_alias 'text/html', shape }
22
23
  end
23
24
  end
24
25
  end
@@ -0,0 +1,55 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The grid a table of events is read as: the seven days across it, the hours down
4
+ # it, and how tall the whole of it stands. What the week is called is `Weeks`.
5
+ module Calendars
6
+ # The hours a week with nothing in it draws: a working day, which is what a
7
+ # reader moving through empty weeks has to place the next one's rows against.
8
+ DEFAULT_HOURS = (8...18)
9
+
10
+ private
11
+
12
+ # The seven days the grid draws, Sunday first.
13
+ def calendar_days(week) = week..(week + 6)
14
+
15
+ # A day's heading, `Sun, Aug 30`, and today's said louder — the one column a reader
16
+ # is looking for before they read a single row.
17
+ def calendar_day_title(day)
18
+ tag.span l(day, format: :recourse_day),
19
+ class: day == Time.zone.today ? 'fw-semibold' : 'fg-2'
20
+ end
21
+
22
+ # How tall the grid stands, whatever hours it draws, and why the figure is stated
23
+ # here: a table's own first page is `LIMITS.first` rows, so a grid that height
24
+ # leaves the weeks under a calendar where the pages under a table already are. A
25
+ # row is its line box, the two paddings a cell keeps — Bootstrap's
26
+ # `--bs-table-cell-padding-y`, which is declared inside a table and nowhere a
27
+ # calendar could read it — and the rule under it.
28
+ def calendar_grid_style
29
+ "height: calc(#{Recourse::LIMITS.first} * (1em * var(--bs-body-line-height) + " \
30
+ '1rem + var(--bs-border-width)))'
31
+ end
32
+
33
+ # The hours the grid runs between, one band of that height each. Remembered per
34
+ # render: every one of the eight columns asks, and a page draws one grid.
35
+ def calendar_hours(recourses)
36
+ @recourse_hours ||= hours_of recourses
37
+ end
38
+
39
+ # From the hour the week's earliest row opens in to the one its latest closes
40
+ # in, so a week of evenings is a grid of evenings rather than a day of empty
41
+ # bands. `blank?` and not `empty?`: the rows are about to be drawn either way.
42
+ def hours_of(recourses)
43
+ return DEFAULT_HOURS if recourses.blank?
44
+
45
+ minutes = recourses.map { |one| event_minutes one }
46
+
47
+ (minutes.map(&:first).min / 60)...(minutes.map(&:last).max / 60.0).ceil
48
+ end
49
+
50
+ # An hour of the scale, `9am`, read off a clock rather than named in Ruby: which
51
+ # words those are is a locale's to say, like every other time on the page.
52
+ def calendar_hour_title(hour) = l(Time.zone.now.change(hour:), format: :recourse_hour)
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,79 @@
1
+ module Recourse
2
+ module Helpers
3
+ # What a calendar draws of one row: the lane it is given where a day's rows overlap,
4
+ # and what it says once it has one. Where it is placed is `Spans`.
5
+ module Events
6
+ private
7
+
8
+ # One day's rows, in the lanes that day needed: a row takes the first lane whose
9
+ # last row has closed, so rows that overlap stand side by side. The lanes are
10
+ # counted over the whole day rather than over each run of overlaps — a column is
11
+ # a day, and two days are read against each other.
12
+ def calendar_events(recourses, day, hours)
13
+ lanes = []
14
+ placed = recourses.select { |one| opens_on? one, day }
15
+ .map { |one| [one, take_lane(one, lanes)] }
16
+
17
+ safe_join(placed.map { |one, lane| event_chip one, hours, lane, lanes.size })
18
+ end
19
+
20
+ def opens_on?(recourse, day) = recourse.attributes[Recourse::START_COLUMN].to_date == day
21
+
22
+ # The first lane free by the time this row opens, or one of its own, with the
23
+ # minute it closes at left in it for whatever comes next.
24
+ def take_lane(recourse, lanes)
25
+ opening, closing = event_minutes recourse
26
+ lane = lanes.index { |taken| taken <= opening } || lanes.size
27
+ lanes[lane] = closing
28
+
29
+ lane
30
+ end
31
+
32
+ # One row, placed by the hours it runs between and saying what it is.
33
+ def event_chip(recourse, hours, lane, lanes)
34
+ tag.div event_words(recourse),
35
+ class: 'position-absolute overflow-hidden small rounded border bg-1 px-1',
36
+ style: event_style(recourse, hours, lane, lanes)
37
+ end
38
+
39
+ # What it says, a line each: its own label, whoever it is for, and the hours. In
40
+ # that order because a chip is as tall as the row is long — an hour at the foot of
41
+ # a full week is two lines — so what is clipped first is what the grid itself has
42
+ # already said.
43
+ def event_words(recourse)
44
+ label = led Recourse.record_title(recourse), resource_model.name, recourse.id
45
+ named = event_references recourse
46
+
47
+ safe_join [
48
+ tag.div(label, class: 'text-truncate'),
49
+ named && tag.div(named, class: 'fg-2 text-truncate'),
50
+ tag.div(event_times(recourse), class: 'fg-2 text-truncate'),
51
+ ].compact
52
+ end
53
+
54
+ # Whoever the row points at, each led to its own page: a shift is read as whose
55
+ # it is after it is read as what it is. Every `belongs_to` the model can follow,
56
+ # and the same label a table's own cell would draw for it — already loaded, since
57
+ # the index eager-loads every one of them.
58
+ def event_references(recourse)
59
+ named = resource_model.recourse_references.filter_map do |association|
60
+ named_cell recourse, association
61
+ end
62
+
63
+ safe_join named, ' · ' if named.any?
64
+ end
65
+
66
+ # The two ends of a row, each carrying what a machine reads, like every other
67
+ # time on these pages.
68
+ def event_times(recourse)
69
+ ends = Recourse::EVENT_COLUMNS.filter_map do |column|
70
+ value = recourse.attributes[column]
71
+
72
+ time_tag value, l(value, format: :recourse_time) if value
73
+ end
74
+
75
+ safe_join ends, '–'
76
+ end
77
+ end
78
+ end
79
+ end
@@ -1,25 +1,10 @@
1
1
  module Recourse
2
2
  module Helpers
3
- # The map a table of places is read as, and the link under either that leads to
4
- # the other.
3
+ # The map a table of places is read as. Which shapes a page has and the links
4
+ # between them are `Shapes`, since a map is only one of them.
5
5
  module Maps
6
6
  private
7
7
 
8
- # Whether this page is the map rather than the table.
9
- def map_view? = request.format.map?
10
-
11
- # The link under the rows to the other shape of the page, or nothing where the
12
- # model keeps neither a place nor a point to draw one by. The query goes with it —
13
- # the search, the sort and the page — so the map shows the rows the table did.
14
- def view_link
15
- return unless Recourse.mappable? resource_model
16
-
17
- params = request.query_parameters
18
- return link_to t('recourse.as_table'), url_for(params.merge(format: nil)) if map_view?
19
-
20
- link_to t('recourse.as_map'), url_for(params.merge(format: :map))
21
- end
22
-
23
8
  # What the map controller needs: the key and the map from the host's credentials,
24
9
  # and this page's rows as the places or the points they keep.
25
10
  def map_data(recourses)
@@ -24,11 +24,20 @@ module Recourse
24
24
  filters = resource_filter_fields
25
25
  return if field.blank? && filters.empty?
26
26
 
27
- render 'recourses/search', query: resource_search, url: url_for(action: :index),
27
+ render 'recourses/search', query: resource_search, url: search_url,
28
28
  field: field, prompt: resource_search_prompt,
29
29
  filters: filters, sort: sort_param
30
30
  end
31
31
 
32
+ # Where the form submits: this page's own shape, so a filter picked on the map or
33
+ # on the calendar answers with the map or the calendar. Only a shape's format goes
34
+ # into the address — the table is the page at its own, with no extension at all.
35
+ def search_url
36
+ format = request.format.symbol
37
+
38
+ url_for action: :index, format: (format if Recourse::SHAPES.include? format)
39
+ end
40
+
32
41
  # The model's search field, less the reach-through a nested route already
33
42
  # answered: a page pinned to one provider offers no box to search them all.
34
43
  def resource_search_field
@@ -0,0 +1,42 @@
1
+ module Recourse
2
+ module Helpers
3
+ # The shapes one page of rows can be read in — a table, a map of where they are, a
4
+ # calendar of when they are — and the links from the one showing to the others.
5
+ module Shapes
6
+ private
7
+
8
+ # Whether this page is the map rather than the table.
9
+ def map_view? = request.format.map?
10
+
11
+ # Whether this page is the calendar rather than the table.
12
+ def calendar_view? = request.format.cal?
13
+
14
+ # What this page can be, by the format each shape is addressed as and the word
15
+ # the link to it reads: the table every model has, and whichever of the other
16
+ # two the model's own columns earn.
17
+ def view_shapes
18
+ model = resource_model
19
+ shapes = { html: 'recourse.as_table' }
20
+ shapes[:map] = 'recourse.as_map' if Recourse.mappable? model
21
+ shapes[:cal] = 'recourse.as_calendar' if Recourse.calendarable? model
22
+
23
+ shapes
24
+ end
25
+
26
+ # A link to each shape the page is not in, carrying the query it was asked with —
27
+ # the search, the sort and the page — so another shape shows the rows the table
28
+ # did. The week stays behind: only a calendar has one, and a calendar reached
29
+ # from the table opens on this week.
30
+ def view_links
31
+ shapes = view_shapes
32
+ query = request.query_parameters.except 'week'
33
+
34
+ # The table is the page at its own address, which is the one shape asked for by
35
+ # carrying no extension at all.
36
+ shapes.except(request.format.symbol).map do |shape, key|
37
+ link_to t(key), url_for(query.merge(format: (shape unless shape == :html)))
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,59 @@
1
+ module Recourse
2
+ module Helpers
3
+ # How much of a day one row takes, and where in the grid that puts it. Every figure
4
+ # here is minutes on the way in and a share of the grid on the way out, so a row and
5
+ # the hours behind it agree however tall an hour is drawn.
6
+ module Spans
7
+ # Minutes in a day, which is where a row running into the next one stops: a
8
+ # calendar draws a row on the day it opens, and a block past the foot of its
9
+ # column would be drawn over the week.
10
+ DAY_MINUTES = 1440
11
+
12
+ # And the least a row may be drawn as, so one nobody gave a length to is still
13
+ # something to read and to click rather than a line.
14
+ LEAST_MINUTES = 15
15
+
16
+ private
17
+
18
+ # Where the row sits: how far down the grid it opens, how much of the grid it
19
+ # takes, and which lane of its day it was given.
20
+ def event_style(recourse, hours, lane, lanes)
21
+ width = 100.0 / lanes
22
+ shares = event_shares(recourse, hours).merge left: lane * width, width: width
23
+
24
+ shares.map { |side, share| "#{side}: #{share.round 2}%" }.join '; '
25
+ end
26
+
27
+ # The two the hours decide: everything above the row, and the row itself.
28
+ def event_shares(recourse, hours)
29
+ opening, closing = event_minutes recourse
30
+ minutes = hours.count * 60.0
31
+
32
+ {
33
+ top: (opening - (hours.first * 60)) * 100 / minutes,
34
+ height: (closing - opening) * 100 / minutes,
35
+ }
36
+ end
37
+
38
+ # When a row opens and closes, as minutes from the midnight of the day it opens
39
+ # on — the two figures every measurement above is made from.
40
+ def event_minutes(recourse)
41
+ opening = recourse.attributes[Recourse::START_COLUMN]
42
+ opened = minute_of opening
43
+
44
+ [opened, [closing_minute(opening, recourse), opened + LEAST_MINUTES].max]
45
+ end
46
+
47
+ # Midnight for a row that runs into the next day, and for one nothing has closed
48
+ # yet — an end nobody has written is a row still open rather than one over at once.
49
+ def closing_minute(opening, recourse)
50
+ closing = recourse.attributes[Recourse::FINISH_COLUMN]
51
+ return DAY_MINUTES if closing.nil? || closing.to_date != opening.to_date
52
+
53
+ minute_of closing
54
+ end
55
+
56
+ def minute_of(value) = (value.hour * 60) + value.min
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,53 @@
1
+ module Recourse
2
+ module Helpers
3
+ # What a week is called, and the ways out of one into the next.
4
+ module Weeks
5
+ # The four ways out of a week, in the order they stand, each as the weeks it moves
6
+ # and the arrow it is drawn as. The outer pair jump four weeks, which is a month as
7
+ # near as a grid drawn in weeks can hold one.
8
+ WEEK_STEPS = {
9
+ previous_weeks: [-4, '<<'], previous_week: [-1, '<'],
10
+ next_week: [1, '>'], next_weeks: [4, '>>'],
11
+ }.freeze
12
+
13
+ private
14
+
15
+ # The week the grid draws, named as the two days it runs between: `September 13th
16
+ # – September 19th, 2026`. The year is said once, at the end, being the one thing
17
+ # the two ends of a week almost always agree about.
18
+ def week_title(week)
19
+ finish = week + 6
20
+
21
+ t 'recourse.week', from: week_day(week), to: week_day(finish), year: finish.year
22
+ end
23
+
24
+ def week_day(day) = "#{l day, format: :recourse_month} #{day.day.ordinalize}"
25
+
26
+ # The ways out of a week, drawn as a table's pages are drawn — pagy's own markup,
27
+ # down to the arrows and to the item it leaves unlinked — with the week showing
28
+ # named in the middle, where pagy names the page.
29
+ def week_links(week)
30
+ shown = tag.a week_title(week), role: :link, class: 'page-link',
31
+ aria: { current: :page, disabled: true }
32
+
33
+ safe_join [
34
+ week_step(week, :previous_weeks), week_step(week, :previous_week),
35
+ tag.li(shown, class: 'page-item active'),
36
+ week_step(week, :next_week), week_step(week, :next_weeks),
37
+ ]
38
+ end
39
+
40
+ # One step: its own arrow, and the words behind it for a reader who is hearing
41
+ # the page rather than seeing it. The query goes with the week, so a search or a
42
+ # filter stands while the weeks move under it.
43
+ def week_step(week, key)
44
+ weeks, arrow = WEEK_STEPS[key]
45
+ query = request.query_parameters.merge week: (week + weeks.weeks).to_s
46
+ step = link_to arrow, url_for(query.merge(format: :cal)),
47
+ class: 'page-link', aria: { label: t("recourse.#{key}") }
48
+
49
+ tag.li step, class: "page-item #{weeks.negative? ? 'previous' : 'next'}"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -3,6 +3,7 @@ require_relative 'helpers/attachments'
3
3
  require_relative 'helpers/blobs'
4
4
  require_relative 'helpers/bookmarks'
5
5
  require_relative 'helpers/buttons'
6
+ require_relative 'helpers/calendars'
6
7
  require_relative 'helpers/cards'
7
8
  require_relative 'helpers/cells'
8
9
  require_relative 'helpers/choices'
@@ -13,6 +14,7 @@ require_relative 'helpers/counters'
13
14
  require_relative 'helpers/deletions'
14
15
  require_relative 'helpers/densities'
15
16
  require_relative 'helpers/details'
17
+ require_relative 'helpers/events'
16
18
  require_relative 'helpers/examples'
17
19
  require_relative 'helpers/fields'
18
20
  require_relative 'helpers/filters'
@@ -34,25 +36,28 @@ require_relative 'helpers/routing'
34
36
  require_relative 'helpers/rows'
35
37
  require_relative 'helpers/themes'
36
38
  require_relative 'helpers/searches'
39
+ require_relative 'helpers/shapes'
37
40
  require_relative 'helpers/shortcuts'
38
41
  require_relative 'helpers/sidebars'
39
42
  require_relative 'helpers/sorts'
43
+ require_relative 'helpers/spans'
40
44
  require_relative 'helpers/tabs'
41
45
  require_relative 'helpers/times'
42
46
  require_relative 'helpers/values'
47
+ require_relative 'helpers/weeks'
43
48
  require_relative 'helpers/zones'
44
49
 
45
50
  module Recourse
46
51
  # View helpers for the pages the gem renders, and what the parts share.
47
52
  module Helpers
48
- include Actions, Attachments, Blobs, Bookmarks, Buttons, Cards,
53
+ include Actions, Attachments, Blobs, Bookmarks, Buttons, Calendars, Cards,
49
54
  Cells, Choices, Colors, Comboboxes, Constraints, Counters, Deletions, Densities,
50
55
  Details,
51
- Examples, Fields, Filters, Flashes, Formats, Inputs, Kinds, Limits, Maps,
56
+ Events, Examples, Fields, Filters, Flashes, Formats, Inputs, Kinds, Limits, Maps,
52
57
  Names, Navigation, Parents, Pictures, Previews, References, Refreshes,
53
58
  Routing,
54
- Resources, Rows, Searches, Shortcuts, Sidebars, Sorts, Tabs, Themes,
55
- Times, Values, Zones
59
+ Resources, Rows, Searches, Shapes, Shortcuts, Sidebars, Sorts, Spans, Tabs,
60
+ Themes, Times, Values, Weeks, Zones
56
61
 
57
62
  # The grid a record's own two pages lay an attribute out in: two columns on a large
58
63
  # viewport, and the same padding on both, so a value and the field that edits it sit
@@ -25,7 +25,7 @@ module Recourse
25
25
  # Where the palettes are served from, named once: the layout links one, the layout's
26
26
  # own script puts back the one a reader chose, and the sidebar's controller swaps it.
27
27
  # The version is the one the layout links its stylesheet and script at.
28
- THEMES_PATH = 'https://design.houseaccount.com/v0.5.0/theme'
28
+ THEMES_PATH = 'https://cdn.jsdelivr.net/npm/houseaccount@0.11.0/public/theme'
29
29
 
30
30
  # Where a reader's chosen palette and mode are kept in their browser. Named once for
31
31
  # the same reason: the sidebar's controller writes it and the layout reads it back.
@@ -1,4 +1,4 @@
1
1
  module Recourse
2
2
  # Version of the gem, read by the gemspec and by hosts checking compatibility.
3
- VERSION = '5.3.0'
3
+ VERSION = '5.4.0'
4
4
  end
data/lib/recourse.rb CHANGED
@@ -7,6 +7,7 @@ require_relative 'recourse/version'
7
7
  require_relative 'recourse/attachments'
8
8
  require_relative 'recourse/blobs'
9
9
  require_relative 'recourse/bookmarks'
10
+ require_relative 'recourse/calendars'
10
11
  require_relative 'recourse/colors'
11
12
  require_relative 'recourse/columns'
12
13
  require_relative 'recourse/themes'
@@ -35,6 +36,11 @@ module Recourse
35
36
  # them. Named once: three places ask which columns these are.
36
37
  TIMESTAMPS = %w[created_at updated_at].freeze
37
38
 
39
+ # The shapes a page of rows takes besides the table, by the format each is asked
40
+ # for. Every one of them is the same HTML drawn another way, which is why none has
41
+ # a template of its own and all three answer to one controller action.
42
+ SHAPES = %i[map cal].freeze
43
+
38
44
  class << self
39
45
  # Resources `recourses` has drawn, in the order config/routes.rb lists them.
40
46
  attr_reader :declared
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: recourse
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.3.0
4
+ version: 5.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - claudiob
@@ -130,12 +130,14 @@ files:
130
130
  - app/controllers/concerns/recourse/parent_resolution.rb
131
131
  - app/controllers/concerns/recourse/reference_resolution.rb
132
132
  - app/controllers/concerns/recourse/resource_resolution.rb
133
+ - app/controllers/concerns/recourse/weeks.rb
133
134
  - app/controllers/concerns/recourse/zoning.rb
134
135
  - app/controllers/recourse/base_controller.rb
135
136
  - app/controllers/recourse/bookmarks_controller.rb
136
137
  - app/controllers/recourses_controller.rb
137
138
  - app/views/layouts/recourses.html.erb
138
139
  - app/views/recourses/_breadcrumb.html.erb
140
+ - app/views/recourses/_calendar.html.erb
139
141
  - app/views/recourses/_card.html.erb
140
142
  - app/views/recourses/_color.html.erb
141
143
  - app/views/recourses/_combobox.html.erb
@@ -152,6 +154,7 @@ files:
152
154
  - app/views/recourses/_sidebar.html.erb
153
155
  - app/views/recourses/_table.html.erb
154
156
  - app/views/recourses/_values.html.erb
157
+ - app/views/recourses/_week.html.erb
155
158
  - app/views/recourses/edit.html.erb
156
159
  - app/views/recourses/index.html.erb
157
160
  - app/views/recourses/new.html.erb
@@ -162,6 +165,7 @@ files:
162
165
  - lib/recourse/blobs.rb
163
166
  - lib/recourse/bookmarks.rb
164
167
  - lib/recourse/broadcasting.rb
168
+ - lib/recourse/calendars.rb
165
169
  - lib/recourse/colors.rb
166
170
  - lib/recourse/columns.rb
167
171
  - lib/recourse/controllers.rb
@@ -173,6 +177,7 @@ files:
173
177
  - lib/recourse/helpers/blobs.rb
174
178
  - lib/recourse/helpers/bookmarks.rb
175
179
  - lib/recourse/helpers/buttons.rb
180
+ - lib/recourse/helpers/calendars.rb
176
181
  - lib/recourse/helpers/cards.rb
177
182
  - lib/recourse/helpers/cells.rb
178
183
  - lib/recourse/helpers/choices.rb
@@ -183,6 +188,7 @@ files:
183
188
  - lib/recourse/helpers/deletions.rb
184
189
  - lib/recourse/helpers/densities.rb
185
190
  - lib/recourse/helpers/details.rb
191
+ - lib/recourse/helpers/events.rb
186
192
  - lib/recourse/helpers/examples.rb
187
193
  - lib/recourse/helpers/fields.rb
188
194
  - lib/recourse/helpers/filters.rb
@@ -203,13 +209,16 @@ files:
203
209
  - lib/recourse/helpers/routing.rb
204
210
  - lib/recourse/helpers/rows.rb
205
211
  - lib/recourse/helpers/searches.rb
212
+ - lib/recourse/helpers/shapes.rb
206
213
  - lib/recourse/helpers/shortcuts.rb
207
214
  - lib/recourse/helpers/sidebars.rb
208
215
  - lib/recourse/helpers/sorts.rb
216
+ - lib/recourse/helpers/spans.rb
209
217
  - lib/recourse/helpers/tabs.rb
210
218
  - lib/recourse/helpers/themes.rb
211
219
  - lib/recourse/helpers/times.rb
212
220
  - lib/recourse/helpers/values.rb
221
+ - lib/recourse/helpers/weeks.rb
213
222
  - lib/recourse/helpers/zones.rb
214
223
  - lib/recourse/icons.rb
215
224
  - lib/recourse/limits.rb