Drop the Linear concepts GitHub has no field for, and render the lists

6fb2c9cf69a9 · AtlantisPleb · · parent 239e5662583e

Drop the Linear concepts GitHub has no field for, and render the lists

The owner's ruling: no custom fields, drop Linear concepts rather than munge
them onto GitHub, GitHub API only. The strategy doc argued the opposite --
`issue_field_values` is genuinely how GitHub models a richer status -- so its
recommendation is reversed and the reason kept, because it is the better half
of the argument: a field only our UI writes and only our UI reads is a second
model wearing the first one's clothes, and every client that does not know
about it sees an issue that is subtly wrong. Two honest states beat six that
one client understands.

So status is `open` or `closed`, with `not_planned` taking the cancelled glyph
because it is the one close reason that reads differently. Priority is gone
entirely -- not a field, not a label convention. Cycles are gone; milestones
are what GitHub has. `issue_priority/1` stays in the library unused, the way
`pricing_column/1` does: an unused component costs a page in a catalogue, an
invented field costs every client.

The issue and project lists now render the ported rows, passing only GitHub
fields. Two facts the rows could not carry have been added to `issue_row/1`,
both native to the payload and both absent from Circle's vocabulary: the
comment count, and who opened the issue. The count renders only when there is
one, since a column of zeroes is noise.

Three things the rewrite would have quietly lost, caught by tests that were
asserting real behaviour rather than markup: the author, the comment count,
and the project delete control. Delete is restored beside the row rather than
inside it -- the row is a link to a board, and a destructive action inside a
link target is how people delete things by accident. Two more came out of it:
`view_tabs/1` can `patch` as well as `navigate`, because tabs filtering one
collection belong to the same LiveView and should not remount it, and the
assignee avatar names its person on hover, which a bare face cannot do.

The sidebar footer reads Documentation, Components, Leaderboard, Admin, and
the application's own rail carries the wordmark beside the mark as one link
home. The docs and component shells still do not, since the section name is
already doing that half of the job.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149rBWy7br1Z7bbz9NrQhEr
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/css/openagents.css
  • modified docs/2026-08-20-linear-design-github-shape.md
  • modified lib/openagents_web/components/circle.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/live/issue_index_live.ex
  • modified lib/openagents_web/live/project_index_live.ex
  • modified test/openagents_web/live/project_index_live_test.exs

Diff

7 files changed, +309 -278

assets/css/openagents.css modified +57

@@ -6160,3 +6160,60 @@

6160 6160
    .member-row__joined { display: inline-flex; width: 88px; }
6161 6161
  }
6162 6162
}
6163
6164
@layer components {
6165
  /* The lockup is one link, so the mark and the word sit on one baseline with
6166
     the same 10px the divider uses on its right -- the eye should read
6167
     "OpenAgents" as labelling the glyph, not as a separate control. */
6168
  .sidebar-brand__mark {
6169
    display: inline-flex;
6170
    align-items: center;
6171
    gap: 10px;
6172
  }
6173
6174
  .sidebar-brand__wordmark {
6175
    color: var(--text-primary);
6176
    font-size: 0.9375rem;
6177
    font-weight: 700;
6178
    letter-spacing: -0.01em;
6179
  }
6180
}
6181
6182
@layer components {
6183
  /* The comment count sits with the other trailing facts, quiet until there is
6184
     something to say. */
6185
  .issue-row__comments {
6186
    display: inline-flex;
6187
    flex: none;
6188
    align-items: center;
6189
    gap: 4px;
6190
    color: var(--text-dim);
6191
    font-size: 0.75rem;
6192
    font-variant-numeric: tabular-nums;
6193
  }
6194
6195
  .issue-row__comments .icon {
6196
    width: 14px;
6197
    height: 14px;
6198
    font-size: 14px;
6199
  }
6200
}
6201
6202
@layer components {
6203
  /* A project row plus its own destructive control. The row fills the width and
6204
     the control sits outside it, so clicking the row can only ever navigate. */
6205
  .project-list__row {
6206
    display: flex;
6207
    align-items: center;
6208
    gap: 8px;
6209
  }
6210
6211
  .project-list__row > :first-child {
6212
    min-width: 0;
6213
    flex: 1;
6214
  }
6215
6216
  .project-list__delete {
6217
    flex: none;
6218
  }
6219
}
docs/2026-08-20-linear-design-github-shape.md modified +69 -77

@@ -10,7 +10,7 @@ The principle, stated by the owner:

10 10
This doc says how those two survive each other. It is a strategy note, not a
11 11
plan of record: the tracker for the component work is
12 12
`docs/2026-08-20-circle-ui-port.md`, and the URL/API compatibility target is
13
`~/work/sarah/docs/audits/2026-08-19-github-clone-harvest-candidates.md`.
13
`docs/github-api-issues-projects-assessment.md`.
14 14
15 15
## The two commitments are about different layers
16 16

@@ -33,78 +33,62 @@ cleanly on one side.

33 33
34 34
## Where they actually collide
35 35
36
Three places, and only three. Everything else is a false conflict.
36
Three places. The owner's ruling on all three is the same and it is the
37
strict one:
37 38
38
### 1. Status: two categories versus six
39
> I do not want custom fields. I would rather drop Linear concepts than munge
40
> them onto GitHub. Let us do GitHub API things only.
39 41
40
GitHub's issue has `state`, and it is binary — `open` or `closed` — with
41
`state_reason` narrowing a close (`completed`, `not_planned`, `duplicate`,
42
`reopened`). Circle's issue has six: backlog, todo, in progress, in review,
43
done, cancelled.
42
So the resolution below is not "find a GitHub-shaped home for every Linear
43
idea." It is: **if GitHub has no field for it, we do not have the concept.**
44 44
45
The temptation is to add a `status` column. That breaks parity the moment a
46
client reads it, because the client asked for a GitHub issue.
45
### 1. Status: two categories, not six
47 46
48
**GitHub has already solved this, and recently.** The current issue schema
49
carries `issue_field_values`, an array of typed custom fields:
47
GitHub's issue has `state` — `open` or `closed` — with `state_reason`
48
narrowing a close (`completed`, `not_planned`, `duplicate`, `reopened`).
49
Circle has six categories.
50 50
51
```
52
issue-field-value: issue_field_id, issue_field_name, data_type, value,
53
                   single_select_option, multi_select_options
54
data_type: text | single_select | multi_select | number | date
55
```
51
We take GitHub's two. An issue is open or it is closed, and a closed one may
52
say why. Backlog, todo, in progress and in review are **not modelled**. They
53
are not renamed, not approximated by labels, and not stored anywhere.
56 54
57
A `single_select` field named Status, whose options are the six categories, is
58
**exactly** how GitHub models this today. It is not a workaround. We already
59
have `project_fields` with `name`, `data_type` and `options`, which is the same
60
shape one level up.
55
What survives is the *rendering*: `issue_status/1` draws a ring for open and a
56
filled check for closed, which is a better-looking pair of glyphs than the two
57
we had, and costs nothing in the contract.
61 58
62
The mapping that keeps both promises:
59
A note for later, recorded because it will come up: the current GitHub schema
60
does carry `issue_field_values` — typed custom fields with `single_select`
61
among the data types — and that is genuinely how GitHub itself models a
62
richer status today. It was the obvious answer and the owner has declined it.
63
The reason is worth keeping: a field that only our UI writes and only our UI
64
reads is a second model wearing the first one's clothes, and every client that
65
does not know about it sees an issue that is subtly wrong. Two honest states
66
beat six states that only one client understands.
63 67
64
| Reading | Writing |
65
| --- | --- |
66
| `state` stays derived: anything but done/cancelled is `open` | a client writing `state: "closed"` sets the Status field to done |
67
| `state_reason` derives too: cancelled → `not_planned` | a client writing `state_reason: "not_planned"` sets cancelled |
68
| the Status field is the richer truth | the UI writes the field; `state` follows |
68
### 2. Priority: dropped
69 69
70
A GitHub client sees an issue that opens and closes. A person sees six
71
categories. Neither is being lied to, because `open` genuinely is "not done and
72
not cancelled".
70
GitHub has no priority field. We do not add one, and we do not adopt the
71
`priority: high` label convention either — a label is a flat, user-defined
72
tag, and an ordered scale pushed through it is a convention, not a contract.
73 73
74
### 2. Priority: GitHub has no such field
75
76
Circle has four levels. GitHub has none — priority is conventionally a label
77
(`priority: high`), which is why every GitHub-shaped tool grew a label
78
convention.
79
80
Same answer, and it is the better one: a `single_select` custom field named
81
Priority. Labels stay labels — a flat, user-defined vocabulary — instead of
82
being overloaded into an ordered scale they cannot express. `issue_priority/1`
83
already renders four levels from one shape, so the UI is waiting on the field,
84
not the other way round.
85
86
If a client expects the label convention, emit **both**: the field is the
87
truth, a `priority: high` label is a projection for tools that only read
88
labels. Projections are cheap; a second source of truth is not.
74
`issue_priority/1` stays in the component library unused, the way
75
`pricing_column/1` does. When there is a GitHub-shaped place for it, the
76
renderer is ready. Until then the column simply is not there.
89 77
90 78
### 3. Grouping and cycles
91 79
92
Circle groups by status, assignee, priority, or project, and has cycles.
93
GitHub's list endpoint filters (`state`, `labels`, `assignee`, `milestone`,
94
`since`) but does not group, and has milestones rather than cycles.
80
Grouping is free and we take it. The list endpoint returns issues; the page
81
groups them by state, assignee, label, or milestone. No parameter is added to
82
a GitHub-shaped endpoint, and no data changes — grouping is arithmetic over a
83
response.
95 84
96
Grouping is a **view** concern: the list endpoint returns issues, the page
97
groups them. Nothing to reconcile — do not add a `group_by` parameter to a
98
GitHub-shaped endpoint.
99
100
Cycles are not milestones and should not pretend to be. A milestone has a due
101
date and a fixed scope; a cycle is a repeating window that rolls unfinished
102
work forward. Until there is a reason to model cycles, milestones are the
103
GitHub-shaped answer and the UI should say "milestone".
85
Cycles are dropped. A milestone has a due date and a fixed scope; a cycle is a
86
repeating window that rolls unfinished work forward. GitHub has the first, so
87
we have the first, and the UI calls it a milestone.
104 88
105 89
## What GitHub gives us that Linear's model does not
106 90
107
Worth noting, because the parity constraint is not purely a tax. The current
91
Worth stating, because the constraint is not only subtractive. The current
108 92
schema also carries:
109 93
110 94
- **`type`** — an issue type (bug, feature, task) with a colour, enabled per

@@ -113,20 +97,24 @@ schema also carries:

113 97
  parent/child issues.
114 98
- **`issue_dependencies_summary`** — blocking relationships.
115 99
116
Circle's UI has no vocabulary for these, so they are ours to design rather than
117
port. The progress arc from `issue_status/1` is the obvious renderer for
118
`sub_issues_summary.percent_completed` — it already fills from a number.
100
These are real GitHub fields, so they are all fair game, and Circle has no
101
vocabulary for any of them. The progress arc in `issue_status/1` already fills
102
from a number, which makes it the obvious renderer for
103
`sub_issues_summary.percent_completed` — a Linear-derived control displaying a
104
GitHub-native fact, which is exactly the shape this whole document is arguing
105
for.
119 106
120 107
## Rules of thumb
121 108
122 109
1. **A design decision that changes a payload is not a design decision.** If
123
   adopting a Linear pattern would add or rename a JSON field, stop: it belongs
124
   in a custom field, a projection, or the view layer.
125
2. **Custom fields before columns.** Anything Linear has and GitHub does not is
126
   a `single_select` field until proven otherwise. That is GitHub's own answer.
127
3. **Derive the compatibility surface, never the rich one.** `state` is
128
   computed from Status. Never the reverse, or the two drift and the API starts
129
   lying.
110
   adopting a Linear pattern would add or rename a JSON field, stop.
111
2. **No field, no concept.** Anything Linear has and GitHub does not, we do
112
   not have. Not as a custom field, not as a label convention, not as a column
113
   nobody else can read. Drop it and render what remains well.
114
3. **A component may outlive its data.** `issue_priority/1` and
115
   `project_row/1`'s health cell stay in the library, unused, until GitHub has
116
   somewhere to put them. An unused component costs a page in a catalogue; an
117
   invented field costs every client.
130 118
4. **Grouping, sorting, density, and keyboard access are free.** They touch no
131 119
   contract. Take all of them.
132 120
5. **Do not rename GitHub's nouns in the UI.** A milestone is called a

@@ -139,16 +127,20 @@ port. The progress arc from `issue_status/1` is the obvious renderer for

139 127
140 128
## Where this leaves the current work
141 129
142
The Circle components exist and are catalogued; nothing renders them on a real
143
page yet, because doing so needs the Status and Priority fields above. That is
144
the next decision, and it is a schema decision rather than a UI one — which is
145
why the port stopped at the component library rather than guessing at it.
130
The Circle components exist and are catalogued. The port stopped short of
131
rendering them on a real page because it thought a schema decision was
132
pending. Under this ruling there is none: the components take GitHub's fields
133
and leave the rest of their attributes at their defaults, which is what those
134
defaults are for.
135
136
Order that follows from this doc, now that no schema decision is pending:
146 137
147
Order that follows from this doc:
138
1. Wire `issue_row/1`, `issue_group/1` and `issue_toolbar/1` into the issue
139
   index, passing only GitHub-shaped data: state, labels, assignees,
140
   milestone, comment count, number, title, author, timestamps.
141
2. The same for `project_row/1` on the project index.
142
3. Then grouping and sorting on the index, which is arithmetic over the
143
   existing response.
148 144
149
1. Custom fields on issues (`issue_field_values`-shaped), with Status and
150
   Priority as the first two.
151
2. Derive `state` and `state_reason` from Status, both directions, with tests
152
   that a GitHub-shaped client sees no change.
153
3. Then wire `issue_row/1`, `issue_group/1` and `issue_toolbar/1` into the
154
   issue index, which by then is only a view change.
145
The blocker that stopped the component port is gone: there was never a schema
146
decision to make, only a decision not to make one.
lib/openagents_web/components/circle.ex modified +28 -4

@@ -191,7 +191,12 @@ defmodule OpenAgentsWeb.UI.Circle do

191 191
192 192
  def assignee(assigns) do
193 193
    ~H"""
194
    <span class={["assignee", @class]} data-size={@size} {@rest}>
194
    <span
195
      class={["assignee", @class]}
196
      data-size={@size}
197
      title={@name || "Unassigned"}
198
      {@rest}
199
    >
195 200
      <span class="assignee__figure">
196 201
        <UI.avatar
197 202
          :if={@name}

@@ -268,6 +273,12 @@ defmodule OpenAgentsWeb.UI.Circle do

268 273
  attr :project, :string, default: nil
269 274
  attr :due, :string, default: nil, doc: "already formatted; overdue is the caller's judgement"
270 275
  attr :created, :string, default: nil
276
  # GitHub-native facts the source had no vocabulary for. `comments` is on
277
  # every issue payload and is the one number a reader scans a list for after
278
  # the title; `author` is who opened it, which GitHub prints in the same
279
  # breath as when.
280
  attr :comments, :integer, default: nil
281
  attr :author, :string, default: nil
271 282
  attr :assignee, :map, default: nil, doc: "`%{name:, src:, presence:}`; `nil` is unassigned"
272 283
  attr :selected, :boolean, default: false
273 284
  attr :class, :any, default: nil

@@ -297,7 +308,14 @@ defmodule OpenAgentsWeb.UI.Circle do

297 308
          </span>
298 309
        </span>
299 310
        <span :if={@due} class="issue-row__due">Due {@due}</span>
300
        <span :if={@created} class="issue-row__date">{@created}</span>
311
        <span :if={@created} class="issue-row__date">
312
          {@created}<span :if={@author}> by {@author}</span>
313
        </span>
314
        <%!-- Absent rather than zero: "0 comments" is a fact nobody scans a
315
        list for, and a column of zeroes is noise. --%>
316
        <span :if={@comments && @comments > 0} class="issue-row__comments">
317
          <UI.icon name="comment" /> {@comments}
318
        </span>
301 319
        <.assignee
302 320
          name={@assignee && @assignee[:name]}
303 321
          src={@assignee && @assignee[:src]}

@@ -506,7 +524,12 @@ defmodule OpenAgentsWeb.UI.Circle do

506 524
507 525
  slot :tab, required: true do
508 526
    attr :label, :string, required: true
509
    attr :navigate, :any, required: true
527
    # `navigate` remounts; `patch` keeps the mount and lets `handle_params`
528
    # answer. Tabs that filter one collection belong to the same LiveView, so
529
    # they should patch -- but a tab that leads to a different view exists too,
530
    # hence both.
531
    attr :navigate, :any
532
    attr :patch, :any
510 533
    attr :selected, :boolean
511 534
  end
512 535

@@ -515,7 +538,8 @@ defmodule OpenAgentsWeb.UI.Circle do

515 538
    <nav class={["view-tabs", @class]} aria-label={@label} {@rest}>
516 539
      <.link
517 540
        :for={tab <- @tab}
518
        navigate={tab.navigate}
541
        navigate={tab[:navigate]}
542
        patch={tab[:patch]}
519 543
        class="view-tabs__tab"
520 544
        aria-current={tab[:selected] && "page"}
521 545
      >
lib/openagents_web/components/layouts.ex modified +14 -9

@@ -184,8 +184,13 @@ defmodule OpenAgentsWeb.Layouts do

184 184
  def sidebar_brand(assigns) do
185 185
    ~H"""
186 186
    <header class="sidebar-brand">
187
      <%!-- The application's own rail carries the wordmark; the docs and
188
      component shells do not, because the section name beside the divider is
189
      already doing that half of the job and "OpenAgents | Docs" said the brand
190
      twice. Mark and word are one link, so the whole lockup goes home. --%>
187 191
      <.link navigate={~p"/"} class="sidebar-brand__mark" aria-label="OpenAgents home">
188 192
        <img src={~p"/favicon-32x32.png"} alt="" width="24" height="24" />
193
        <span :if={is_nil(@title)} class="sidebar-brand__wordmark">OpenAgents</span>
189 194
      </.link>
190 195
      <span :if={@title} class="sidebar-brand__divider" aria-hidden="true"></span>
191 196
      <.link :if={@title} patch={@path} class="sidebar-brand__title">{@title}</.link>

@@ -213,6 +218,15 @@ defmodule OpenAgentsWeb.Layouts do

213 218
214 219
    ~H"""
215 220
    <footer class="sidebar-footer">
221
      <.link navigate={~p"/docs"} class="sidebar-footer__link">
222
        <UI.icon name="book" /> Documentation
223
      </.link>
224
      <.link :if={@components_link?} navigate={~p"/components"} class="sidebar-footer__link">
225
        <UI.icon name="widget" /> Components
226
      </.link>
227
      <.link navigate={~p"/leaderboard"} class="sidebar-footer__link">
228
        <UI.icon name="trophy-top" /> Leaderboard
229
      </.link>
216 230
      <.link
217 231
        :if={@admin_link?}
218 232
        id="open-admin"

@@ -222,15 +236,6 @@ defmodule OpenAgentsWeb.Layouts do

222 236
      >
223 237
        <UI.icon name="shield-lock" /> Admin
224 238
      </.link>
225
      <.link :if={@components_link?} navigate={~p"/components"} class="sidebar-footer__link">
226
        <UI.icon name="widget" /> Components
227
      </.link>
228
      <.link navigate={~p"/docs"} class="sidebar-footer__link">
229
        <UI.icon name="book" /> Documentation
230
      </.link>
231
      <.link navigate={~p"/leaderboard"} class="sidebar-footer__link">
232
        <UI.icon name="trophy-top" /> Leaderboard
233
      </.link>
234 239
    </footer>
235 240
    """
236 241
  end
lib/openagents_web/live/issue_index_live.ex modified +107 -137

@@ -5,6 +5,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

5 5
  use OpenAgentsWeb, :live_view
6 6
7 7
  alias OpenAgents.Issues
8
  alias OpenAgentsWeb.UI.Circle
8 9
  alias OpenAgents.Repositories
9 10
10 11
  def mount(_params, _session, socket) do

@@ -34,61 +35,40 @@ defmodule OpenAgentsWeb.IssueIndexLive do

34 35
35 36
  def render(assigns) do
36 37
    ~H"""
37
    <Layouts.app flash={@flash} current_scope={@current_scope}>
38
      <div class="bg-background border border-border rounded-lg overflow-hidden">
39
        <div class="flex flex-wrap items-center gap-3 p-4 border-b border-border">
40
          <div class="flex items-center gap-1">
41
            <%!-- These two are the current filter, so the selected one carries
42
            `aria-current` and the accent tone rather than a pressed-button
43
            class; the style pack has no active state for a link. --%>
44
            <.link
38
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Issues" wide>
39
      <Circle.issue_toolbar>
40
        <:leading>
41
          <Circle.view_tabs>
42
            <:tab
43
              label={"#{@open_count} Open"}
45 44
              patch={~p"/#{@owner}/#{@repo}/issues?state=open"}
46
              class={["btn gap-1", @state == "open" && "text-foreground font-semibold"]}
47
              data-variant="ghost"
48
              data-size="sm"
49
              aria-current={@state == "open" && "page"}
50
            >
51
              <.icon name="warning" class="size-4 text-success" />
52
              <span class="font-semibold">{@open_count}</span> Open
53
            </.link>
54
            <.link
45
              selected={@state == "open"}
46
            />
47
            <:tab
48
              label={"#{@closed_count} Closed"}
55 49
              patch={~p"/#{@owner}/#{@repo}/issues?state=closed"}
56
              class={["btn gap-1", @state == "closed" && "text-foreground font-semibold"]}
57
              data-variant="ghost"
58
              data-size="sm"
59
              aria-current={@state == "closed" && "page"}
60
            >
61
              <.icon name="check-circle" class="size-4 text-muted-foreground" />
62
              <span class="font-semibold">{@closed_count}</span> Closed
63
            </.link>
64
          </div>
65
66
          <div class="flex-1 min-w-[12rem]">
67
            <label class="input w-full max-w-xs flex items-center gap-2">
68
              <.icon name="search" class="size-4 text-muted-foreground" />
69
              <input
70
                type="text"
71
                placeholder="Search or filter results..."
72
                class="grow bg-transparent outline-none"
73
              />
74
            </label>
75
          </div>
76
77
          <div class="flex items-center gap-1">
78
            <button class="btn gap-1" data-variant="ghost" data-size="sm">
79
              <.icon name="tag" class="size-4" /> Labels
80
            </button>
81
            <button class="btn gap-1" data-variant="ghost" data-size="sm">
82
              <.icon name="flag" class="size-4" /> Milestones
83
            </button>
84
            <button class="btn gap-1" data-variant="ghost" data-size="sm">
85
              <.icon name="user" class="size-4" /> Assignees
86
            </button>
87
            <button class="btn gap-1" data-variant="ghost" data-size="sm">
88
              <.icon name="filter" class="size-4" /> Sort
89
            </button>
90
          </div>
50
              selected={@state == "closed"}
51
            />
52
          </Circle.view_tabs>
53
        </:leading>
91 54
55
        <:actions>
56
          <.link
57
            navigate={~p"/#{@owner}/#{@repo}/labels"}
58
            class="btn"
59
            data-variant="ghost"
60
            data-size="sm"
61
          >
62
            <.icon name="tag" /> Labels
63
          </.link>
64
          <.link
65
            navigate={~p"/#{@owner}/#{@repo}/milestones"}
66
            class="btn"
67
            data-variant="ghost"
68
            data-size="sm"
69
          >
70
            <.icon name="flag" /> Milestones
71
          </.link>
92 72
          <.link
93 73
            navigate={~p"/#{@owner}/#{@repo}/issues/new"}
94 74
            class="btn"

@@ -97,94 +77,84 @@ defmodule OpenAgentsWeb.IssueIndexLive do

97 77
          >
98 78
            New issue
99 79
          </.link>
100
        </div>
80
        </:actions>
81
      </Circle.issue_toolbar>
101 82
102
        <%= if @issues_count == 0 do %>
103
          <div class="p-8 text-center">
104
            <.icon
105
              name="clipboard"
106
              class="size-12 mx-auto mb-3 text-muted-foreground/50"
107
            />
108
            <h3 class="text-lg font-medium mb-1">
109
              No {if(@state == "open", do: "open", else: "closed")} issues
110
            </h3>
111
            <p class="text-sm text-muted-foreground">
112
              Issues will show up here once they are created.
113
            </p>
114
          </div>
115
        <% else %>
116
          <div id="issues" phx-update="stream" class="divide-y divide-border">
117
            <div
118
              :for={{id, issue} <- @streams.issues}
119
              id={id}
120
              class="p-4 hover:bg-card"
121
            >
122
              <div class="flex items-start gap-4">
123
                <.icon
124
                  name={
125
                    if(issue.state == "open",
126
                      do: "warning",
127
                      else: "check-circle"
128
                    )
129
                  }
130
                  class={[
131
                    "size-5 shrink-0",
132
                    issue.state == "open" && "text-success",
133
                    issue.state == "closed" && "text-muted-foreground"
134
                  ]}
135
                />
136
                <div class="flex-1 min-w-0">
137
                  <h3 class="font-semibold text-base">
138
                    <.link
139
                      navigate={~p"/#{@owner}/#{@repo}/issues/#{issue.number}"}
140
                      class="btn px-0 text-base font-semibold"
141
                      data-variant="link"
142
                    >
143
                      {issue.title}
144
                    </.link>
145
                    <span class="text-muted-foreground font-normal text-sm ml-1">
146
                      #{issue.number}
147
                    </span>
148
                  </h3>
149
                  <p class="text-sm text-muted-foreground mt-1">
150
                    Opened on {Calendar.strftime(issue.inserted_at, "%b %d, %Y")} by {(issue.user &&
151
                                                                                         issue.user[
152
                                                                                           "login"
153
                                                                                         ]) ||
154
                      "anonymous"}
155
                    <%= if issue.comments > 0 do %>
156
                      <span class="inline-flex items-center gap-1 ml-2">
157
                        <.icon name="comment" class="size-4" />
158
                        {issue.comments}
159
                      </span>
160
                    <% end %>
161
                  </p>
162
                  <div class="flex flex-wrap gap-1 mt-2">
163
                    <%= for label <- issue.labels || [] do %>
164
                      <span
165
                        class="badge rounded-full px-2 py-0.5"
166
                        style={"background-color: ##{label["color"]}; color: #000;"}
167
                      >
168
                        {label["name"]}
169
                      </span>
170
                    <% end %>
171
                  </div>
172
                </div>
173
                <div class="avatar-group shrink-0">
174
                  <%= for assignee <- issue.assignees || [] do %>
175
                    <span class="avatar size-6" title={assignee["login"]}>
176
                      <span class="!text-xs">
177
                        {assignee["login"] |> String.first() |> String.upcase()}
178
                      </span>
179
                    </span>
180
                  <% end %>
181
                </div>
182
              </div>
183
            </div>
184
          </div>
185
        <% end %>
83
      <.empty
84
        :if={@issues_count == 0}
85
        id="issues-empty"
86
        title={"No #{@state} issues"}
87
      >
88
        Issues will show up here once they are created.
89
      </.empty>
90
91
      <div :if={@issues_count > 0} id="issues" phx-update="stream" class="issue-list">
92
        <Circle.issue_row
93
          :for={{id, issue} <- @streams.issues}
94
          id={id}
95
          identifier={"##{issue.number}"}
96
          title={issue.title}
97
          navigate={~p"/#{@owner}/#{@repo}/issues/#{issue.number}"}
98
          status_category={category(issue)}
99
          status_label={status_label(issue)}
100
          labels={labels(issue)}
101
          assignee={assignee(issue)}
102
          created={"opened #{relative(issue.inserted_at)} ago"}
103
          author={author(issue)}
104
          comments={issue.comments}
105
        />
186 106
      </div>
187 107
    </Layouts.app>
188 108
    """
189 109
  end
110
111
  # GitHub's two states, and nothing invented on top of them. `not_planned` is
112
  # the one close reason with a distinct reading, so it takes the cancelled
113
  # glyph; every other close is a completion.
114
  defp category(%{state: "closed", state_reason: "not_planned"}), do: :canceled
115
  defp category(%{state: "closed"}), do: :completed
116
  defp category(_issue), do: :unstarted
117
118
  defp status_label(%{state: "closed", state_reason: "not_planned"}), do: "Closed as not planned"
119
  defp status_label(%{state: "closed"}), do: "Closed"
120
  defp status_label(_issue), do: "Open"
121
122
  # A label carries a colour on GitHub; the row takes a tone from our ladder
123
  # rather than that hex, so the list stays in one palette.
124
  defp labels(%{labels: labels}) when is_list(labels) do
125
    Enum.map(labels, fn label ->
126
      %{name: label["name"] || label[:name] || "label", tone: :neutral}
127
    end)
128
  end
129
130
  defp labels(_issue), do: []
131
132
  # GitHub issues carry many assignees; the row shows the first, which is the
133
  # one GitHub itself treats as `assignee`.
134
  defp assignee(%{assignees: [first | _rest]}) when is_map(first) do
135
    %{
136
      name: first["login"] || first[:login],
137
      src: first["avatar_url"] || first[:avatar_url],
138
      presence: :none
139
    }
140
  end
141
142
  defp assignee(_issue), do: nil
143
144
  # GitHub prints who opened an issue beside when. An issue whose author is
145
  # gone still has a history, so it says so rather than showing a blank.
146
  defp author(%{user: %{} = user}), do: user["login"] || user[:login] || "anonymous"
147
  defp author(_issue), do: "anonymous"
148
149
  defp relative(nil), do: nil
150
151
  defp relative(at) do
152
    at = if is_struct(at, NaiveDateTime), do: DateTime.from_naive!(at, "Etc/UTC"), else: at
153
154
    case DateTime.diff(DateTime.utc_now(), at, :second) do
155
      s when s < 3_600 -> "#{max(div(s, 60), 1)}m"
156
      s when s < 86_400 -> "#{div(s, 3_600)}h"
157
      s -> "#{div(s, 86_400)}d"
158
    end
159
  end
190 160
end
lib/openagents_web/live/project_index_live.ex modified +30 -50

@@ -5,6 +5,7 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

5 5
  use OpenAgentsWeb, :live_view
6 6
7 7
  alias OpenAgents.Projects
8
  alias OpenAgentsWeb.UI.Circle
8 9
  alias OpenAgents.Projects.Project
9 10
  alias OpenAgents.Repositories
10 11

@@ -51,11 +52,7 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

51 52
52 53
  def render(assigns) do
53 54
    ~H"""
54
    <Layouts.app flash={@flash} current_scope={@current_scope}>
55
      <div class="flex items-center justify-between mb-4">
56
        <h1 class="text-2xl font-bold">Projects</h1>
57
      </div>
58
55
    <Layouts.app flash={@flash} current_scope={@current_scope} title="Projects" wide>
59 56
      <.form
60 57
        for={@form}
61 58
        id="new-project-form"

@@ -68,52 +65,35 @@ defmodule OpenAgentsWeb.ProjectIndexLive do

68 65
        </footer>
69 66
      </.form>
70 67
71
      <%= if @projects == [] do %>
72
        <div class="alert" data-variant="info" role="status">
73
          <.icon name="info-circle" class="size-5" />
74
          <section>No projects yet.</section>
75
        </div>
76
      <% else %>
77
        <div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
78
          <%= for project <- @projects do %>
79
            <article class="card !m-0">
80
              <header>
81
                <h3 class="card-title">
82
                  <.link
83
                    navigate={~p"/#{@owner}/#{@repo}/projects/#{project.number}"}
84
                    class="btn px-0"
85
                    data-variant="link"
86
                  >
87
                    {project.title}
88
                  </.link>
89
                </h3>
90
                <p>{project.state}</p>
91
              </header>
92
              <footer class="flex justify-end gap-2 mt-4">
93
                <.link
94
                  navigate={~p"/#{@owner}/#{@repo}/projects/#{project.number}"}
95
                  class="btn"
96
                  data-variant="ghost"
97
                  data-size="sm"
98
                >
99
                  View
100
                </.link>
101
                <button
102
                  class="btn"
103
                  data-variant="ghost"
104
                  data-size="sm"
105
                  data-tone="danger"
106
                  phx-click="delete"
107
                  phx-value-id={project.id}
108
                  data-confirm="Delete this project?"
109
                >
110
                  Delete
111
                </button>
112
              </footer>
113
            </article>
114
          <% end %>
68
      <div :if={@projects == []} class="alert" data-variant="info" role="status">
69
        <.icon name="info-circle" class="size-5" />
70
        <section>No projects yet.</section>
71
      </div>
72
73
      <div :if={@projects != []} class="project-list">
74
        <div :for={project <- @projects} class="project-list__row">
75
          <Circle.project_row
76
            name={project.title}
77
            navigate={~p"/#{@owner}/#{@repo}/projects/#{project.number}"}
78
            status_category={if project.state == "closed", do: :completed, else: :unstarted}
79
            status_label={String.capitalize(project.state)}
80
          />
81
          <%!-- Deletion stays a control of its own rather than something the
82
          row grew: the row is a link to a board, and a destructive action
83
          inside a link target is how people delete things by accident. --%>
84
          <button
85
            class="btn project-list__delete"
86
            data-variant="ghost"
87
            data-size="sm"
88
            data-tone="danger"
89
            phx-click="delete"
90
            phx-value-id={project.id}
91
            data-confirm="Delete this project?"
92
          >
93
            Delete
94
          </button>
115 95
        </div>
116
      <% end %>
96
      </div>
117 97
    </Layouts.app>
118 98
    """
119 99
  end
test/openagents_web/live/project_index_live_test.exs modified +4 -1

@@ -34,10 +34,13 @@ defmodule OpenAgentsWeb.ProjectIndexLiveTest do

34 34
             "Roadmap"
35 35
           )
36 36
37
    # The row's name is the link to its board. A separate "View" control beside
38
    # a row that already navigates is one more thing to aim at for the same
39
    # destination.
37 40
    assert has_element?(
38 41
             view,
39 42
             ~s{a[href="/OpenAgentsInc/openagents.com/projects/#{project.number}"]},
40
             "View"
43
             project.title
41 44
           )
42 45
  end
43 46

This page updates live while a promote is in flight · changelog