1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
require "pro_tacts/admin/phlex"

require "pro_tacts/admin/avatar"
require "pro_tacts/admin/format"
require "pro_tacts/admin/group_label"
require "pro_tacts/admin/layout"
require "pro_tacts/admin/list_item"

module ProTacts
  module Admin
    # GET /groups/:id — a group's card, and under it the contacts it
    # holds. Groups are records, not labels (docs/DESIGN.md,
    # "Relationships are navigable"), so the card is a contact's card
    # in every way that carries over: what the group lends on the same
    # type-and-value grid, and an attribute with no data gets no row.
    # The members are what a group is, so they are a list rather than a
    # row: /contacts' rows, narrowed to this group and in its order
    # (Contact#<=>). Under them the group's history, the change
    # log the contact page renders in this page's own shape. The cards
    # that log names (.moved) come in beside the members, because a
    # join or a leave is read by the name of who moved, and one who
    # left is no member; the members list is the narrowing of them.
    # Those and no more, rather than the whole book: a household's
    # page built every card to show four.
    class GroupsShow < Phlex::HTML
      # @rbs @group: Group
      # @rbs @login: String
      # @rbs @reading: Contact
      # @rbs @members: Array[ListedContact]
      # @rbs @named: Hash[String, ListedContact]
      # @rbs @changes: Array[Store::GroupChange]

      #: (group: Group, contacts: Array[ListedContact], changes: Array[Store::GroupChange], login: String) -> void
      def initialize(group:, contacts:, changes:, login:)
        @group = group
        @login = login
        @changes = changes
        @reading = group.reading
        joined = group.members.to_set
        @members = contacts.select { joined.include?(it.id) }.sort
        @named = contacts.to_h { [it.id, it] }
      end

      # The cards a join or a leave in these changes moved: who the log
      # names, beside the members, in the contacts this page is handed.
      #: (Array[Store::GroupChange] changes) -> Array[String]
      def self.moved(changes)
        changes.filter_map {
          it.detail.fetch("card").to_s if [Store::GroupAction::JOIN, Store::GroupAction::LEAVE].include?(it.action)
        }
      end

      def view_template
        render Layout.new(title: @group.label, login: @login) do
          div(class: "record") do
            div(class: "record-nav") do
              a(href: "/groups", class: "type-label") { "‹ groups" }
              a(href: "/groups/#{@group.id}/edit", class: "btn", data_size: "sm") { "edit" }
            end
            div(class: "card") do
              div(class: "card-body") do
                # The group's photo, this page's alone
                # (docs/plans/2026-10-12-group-photos.md). Alt is empty:
                # the name is the heading right under it.
                img(class: "group-photo", src: "/groups/#{@group.id}/photo", alt: "") if @group.photo
                div(class: "detail-header") do
                  h1(class: "type-h2") { render GroupLabel.new(group: @group) }
                end
                # No grid for a group that lends nothing, so its name
                # sits alone in the card (.detail-header in admin.css).
                dl(class: "detail-grid") { rows } if lends?
              end
            end
          end
          members_list
          change_log
        end
      end

      private

      #: () -> bool
      def lends?
        @reading.addresses.any? || @reading.notes.any?
      end

      #: () -> void
      def rows
        @reading.addresses.each do |address|
          row(address.label || "address") do
            Format.address_lines(address).each { |line| div { line } }
          end
        end
        @reading.notes.each do |note|
          row("notes") { div(style: "white-space: pre-wrap;") { note.value } }
        end
      end

      # Rendered even with no members, because its head holds the way
      # to add one. Membership is edited here rather than in the
      # editor, which edits only the lines the group holds; the link
      # opens the members screen (Admin::GroupsMembers), this list
      # made editable. It sits where a section's action does
      # (admin.css).
      #: () -> void
      def members_list
        section do
          div(class: "section-head") do
            h2(class: "type-label") { "members (#{@members.length})" }
            a(href: "/groups/#{@group.id}/members", class: "type-label") { "edit members" }
          end
          if @members.empty?
            p(class: "type-body-sm") { "No members yet." }
          else
            ul(class: "card") do
              @members.each do |member|
                render ListItem.new(
                  href: "/contacts/#{member.id}",
                  avatar: Avatar.new(contact: member, size: "lg"),
                ) { Format.name_label(member) }
              end
            end
          end
        end
      end

      #: (String type) { () -> void } -> void
      def row(type, &)
        dt(class: "type-label") { type }
        dd(class: "type-body-sm", &)
      end

      # What this group has done, under the members it holds: the
      # group's own log (Store#group_changes_of) in the contact page's
      # change-log shape — collapsed, because the record is about the
      # group and a history is about the writes.
      #: () -> void
      def change_log
        div(class: "card") do
          div(class: "card-body") do
            details do
              summary(class: "type-label") { "change log" }
              dl(class: "detail-grid change-log") { change_rows }
            end
          end
        end
      end

      # One row per entry, on the record's own grid — the contact
      # page's layout, its action in the type column and its moment
      # and detail in the value column. No etag beside the moment: a
      # group serves nothing, so no download exists for an etag to
      # describe. A `delete` entry has no arm — it would mean a page
      # rendered for a group that is gone, which is a 404 before any
      # of this runs.
      #: () -> void
      def change_rows
        @changes.each do |change|
          dt(class: "type-label") { change.action }
          dd(class: "type-body-sm") do
            div { Format.stamp(change.created_at) }
            case change.action
            when Store::GroupAction::CREATE then div { change.detail.fetch("name") || @group.id }
            when Store::GroupAction::RENAME then div { rename_detail(change.detail) }
            when Store::GroupAction::LINES then diff_lines(change.detail)
            when Store::GroupAction::JOIN, Store::GroupAction::LEAVE then member_link(change.detail)
            else raise "no arm for #{change.action}"
            end
          end
        end
      end

      # A rename's `was → name`, a null spelled as the group's id —
      # the label rule (Store#group_label), said of the names a group
      # no longer or not yet carries. The arrow, because the pair is
      # one fact about one moment and not two rows of it.
      #: (Hash[String, untyped] detail) -> String
      def rename_detail(detail)
        "#{detail.fetch("was") || @group.id} → #{detail.fetch("name") || @group.id}"
      end

      # A `lines` entry's moved lines, the contact page's own diff
      # rows: removed then added, the sign and not the color saying
      # which, each line elided the way a card's are. An empty diff is
      # a pure reorder — a real entry whose lines all stayed — so it
      # renders nothing rather than an empty block.
      #: (Hash[String, untyped] detail) -> void
      def diff_lines(detail)
        added = detail.fetch("added") #: Array[String]
        removed = detail.fetch("removed") #: Array[String]
        return if added.empty? && removed.empty?

        div(class: "diff type-mono") do
          removed.each { |line| div(class: "diff-removed") { "-#{Format.elide_text(line)}" } }
          added.each { |line| div(class: "diff-added") { "+#{Format.elide_text(line)}" } }
        end
      end

      # The card a join or a leave moved, linked to its page and
      # carrying its name — the members list's own bargain, the log
      # being for a person and a person reading names. A card since
      # deleted renders its id plain, the tombstone's text being all
      # that's left; a card that never was one is dropped by the save
      # (Web#apply_group_edit), so it cannot reach the log.
      #: (Hash[String, untyped] detail) -> void
      def member_link(detail)
        id = detail.fetch("card").to_s
        contact = @named[id]
        if contact
          a(href: "/contacts/#{id}") { Format.name_label(contact) }
        else
          div { id }
        end
      end
    end
  end
end