diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-06 16:09:30 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-06 16:09:30 -0500 |
| commit | 132496a6fbe5b1190d3b5cc585e940d12e3c92db (patch) | |
| tree | 2923408a17a2d38052a384e91c8b667c9ed1e283 | |
| parent | 9564501efe2488b9136dc19a7a8d385b8b074b46 (diff) | |
| download | pearl-132496a6fbe5b1190d3b5cc585e940d12e3c92db.tar.gz pearl-132496a6fbe5b1190d3b5cc585e940d12e3c92db.zip | |
fix(views): order group sections by Linear's board order
A grouped view rendered its sections in first-appearance order: whatever order issues came back from the API. So the columns came out scrambled instead of following Linear's board order (Icebox, then In Progress, then Done). Org's own sort couldn't fix it either, since the buffer carried nothing to sort the sections on.
Group sections now order by a per-dimension key. workflowState orders by state type rank (triage, backlog, unstarted, started, completed, canceled), with the state's position breaking ties within a type, so it matches the board. priority orders Urgent down to None last. project, assignee, and cycle have no board order, so they sort alphabetically. One shared helper feeds both the render path and the interactive regroup, so they can't drift.
For the regroup to order without a refetch, the state position has to be in the buffer, so the issue drawer now carries LINEAR-STATE-POSITION (fetched alongside the name and type pearl already pulled). A buffer rendered before this change has no position field and falls back to ordering by type rank alone until its next fetch.
| -rw-r--r-- | pearl.el | 105 | ||||
| -rw-r--r-- | tests/test-pearl-grouping.el | 58 | ||||
| -rw-r--r-- | tests/test-pearl-output.el | 18 |
3 files changed, 157 insertions, 24 deletions
@@ -1288,7 +1288,8 @@ display name) so the assignee @-tag can use the short form." (when raw (list :id (cdr (assoc 'id raw)) :name (cdr (assoc 'name raw)) - :type (cdr (assoc 'type raw))))) + :type (cdr (assoc 'type raw)) + :position (cdr (assoc 'position raw))))) (defun pearl--normalize-team (raw) "Normalize a Linear team alist RAW to a plist, or nil." @@ -1609,7 +1610,7 @@ populated list renders them, not just the single-issue refresh." issues(filter: $filter, first: $first, after: $after, orderBy: $orderBy) { nodes { id identifier title description priority url updatedAt - state { id name type } + state { id name type position } assignee { id name displayName email } team { id key name } project { id name } @@ -1625,7 +1626,7 @@ populated list renders them, not just the single-issue refresh." "query Issue($id: String!) { issue(id: $id) { id identifier title description priority url updatedAt - state { id name type } + state { id name type position } assignee { id name displayName email } team { id key name } project { id name } @@ -1766,20 +1767,67 @@ view. Returns nil for a grouping pearl doesn't section (see (t (format "Cycle %s" (plist-get cycle :number)))))) (_ nil))) +(defconst pearl--state-type-order + '(("triage" . 0) ("backlog" . 1) ("unstarted" . 2) + ("started" . 3) ("completed" . 4) ("canceled" . 5)) + "Rank of each Linear workflow-state type, matching the board's column order. +Used as the primary key when ordering `workflowState' group sections; the +state's `position' breaks ties within a type.") + +(defun pearl--state-type-rank (type) + "Return the board-order rank of a workflow-state TYPE (unknown sorts last)." + (or (cdr (assoc type pearl--state-type-order)) 6)) + +(defun pearl--priority-group-rank (priority) + "Return the group-order rank of a Linear PRIORITY number. +Urgent first .. Low, then None last -- matching how Linear orders a +priority-grouped board. A nil or 0 priority is None." + (pcase priority (1 0) (2 1) (3 2) (4 3) (_ 4))) + +(defun pearl--order-groups (grouping groups) + "Order GROUPS, a list of (LABEL . ISSUES), by GROUPING's natural order. +`workflowState' orders by (state type rank, state position) so the sections +match Linear's board columns; `priority' orders Urgent..None; every other +dimension sorts alphabetically by label. The sort is stable, so issues that +tie on the key keep their first-appearance order." + (pcase grouping + ("workflowState" + (sort (copy-sequence groups) + (lambda (a b) + (let* ((sa (plist-get (cadr a) :state)) + (sb (plist-get (cadr b) :state)) + (ra (pearl--state-type-rank (plist-get sa :type))) + (rb (pearl--state-type-rank (plist-get sb :type)))) + (if (/= ra rb) + (< ra rb) + (< (or (plist-get sa :position) 0) + (or (plist-get sb :position) 0))))))) + ("priority" + (sort (copy-sequence groups) + (lambda (a b) + (< (pearl--priority-group-rank (plist-get (cadr a) :priority)) + (pearl--priority-group-rank (plist-get (cadr b) :priority)))))) + (_ + (sort (copy-sequence groups) + (lambda (a b) (string< (downcase (or (car a) "")) (downcase (or (car b) "")))))))) + (defun pearl--group-issues (issues grouping) "Partition ISSUES into ordered groups by GROUPING. -Returns a list of (LABEL . ISSUES): groups in first-appearance order, and within -each group the issues keep their input order so the view's configured sort -survives inside every section. Returns nil when GROUPING isn't sectioned (see -`pearl--view-grouping-active-p'), signaling the caller to render the flat list." +Returns a list of (LABEL . ISSUES) ordered by `pearl--order-groups' (Linear's +board order for `workflowState'/`priority', alphabetical otherwise); within each +group the issues keep their input order so the view's configured sort survives. +Returns nil when GROUPING isn't sectioned (see `pearl--view-grouping-active-p'), +signaling the caller to render the flat list." (when (pearl--view-grouping-active-p grouping) (let ((order '()) (table (make-hash-table :test 'equal))) (dolist (issue issues) (let ((label (pearl--view-group-label issue grouping))) (unless (gethash label table) (push label order)) (push issue (gethash label table)))) - (mapcar (lambda (label) (cons label (nreverse (gethash label table)))) - (nreverse order))))) + (pearl--order-groups + grouping + (mapcar (lambda (label) (cons label (nreverse (gethash label table)))) + (nreverse order)))))) (defun pearl--view-issues-query () "GraphQL query running a Custom View's own filter server-side, by view id. @@ -1795,7 +1843,7 @@ and so isn't applied by the connection alone (see issues(first: $first, after: $after, sort: $sort, filter: $filter) { nodes { id identifier title description priority url updatedAt - state { id name type } + state { id name type position } assignee { id name displayName email } team { id key name } project { id name } @@ -2894,6 +2942,7 @@ identifier prefix -- so an unedited heading is a no-op on title sync)." (format ":LINEAR-STATE-ID-SYNCED: %s\n" (or (plist-get state :id) "")) (format ":LINEAR-STATE-NAME: %s\n" (or (plist-get state :name) "")) (format ":LINEAR-STATE-TYPE: %s\n" (or (plist-get state :type) "")) + (format ":LINEAR-STATE-POSITION: %s\n" (or (plist-get state :position) "")) (format ":LINEAR-ASSIGNEE-ID: %s\n" (or (plist-get assignee :id) "")) (format ":LINEAR-ASSIGNEE-ID-SYNCED: %s\n" (or (plist-get assignee :id) "")) (format ":LINEAR-ASSIGNEE-NAME: %s\n" (or (plist-get assignee :name) "")) @@ -7011,6 +7060,18 @@ GROUPING outside the drawer-backed set yields nil." (string-to-number (or (org-entry-get nil "LINEAR-PRIORITY") "0")))) (_ nil))) +(defun pearl--buffer-issue-group-rep () + "Return a minimal issue plist for group ordering, read from the drawer at point. +Carries just the fields `pearl--order-groups' needs -- the state type/position +and the priority -- so a buffer regroup orders its sections the same way the +render path does. Point must be on the issue heading." + (let ((pos (org-entry-get nil "LINEAR-STATE-POSITION")) + (pri (org-entry-get nil "LINEAR-PRIORITY"))) + (list :state (list :type (org-entry-get nil "LINEAR-STATE-TYPE") + :position (and pos (not (string-empty-p pos)) + (string-to-number pos))) + :priority (and pri (not (string-empty-p pri)) (string-to-number pri))))) + (defun pearl--subtree-contains-issue-p (beg end) "Return non-nil when the region BEG..END holds an issue heading (a `LINEAR-ID')." (save-excursion @@ -7054,6 +7115,7 @@ grouped. Returns t, or `no-issues' when there are none." ;; A level-2 issue (flat buffer): already canonical level 2. ((and id (not (string-empty-p id))) (push (list :label (pearl--buffer-issue-group-label grouping) + :rep (pearl--buffer-issue-group-rep) :text (buffer-substring-no-properties beg end)) issues)) ;; A level-2 group heading (contains issues): descend, capture each @@ -7073,6 +7135,7 @@ grouped. Returns t, or `no-issues' when there are none." (iend (save-excursion (org-end-of-subtree t t) (point))) (lvl (org-current-level))) (push (list :label (pearl--buffer-issue-group-label grouping) + :rep (pearl--buffer-issue-group-rep) :text (pearl--shift-heading-levels (buffer-substring-no-properties ibeg iend) (- 2 lvl))) @@ -7088,15 +7151,25 @@ grouped. Returns t, or `no-issues' when there are none." (delete-region content-beg parent-end) (goto-char content-beg) (if grouping - (let ((order '()) (table (make-hash-table :test 'equal))) + (let ((order '()) (table (make-hash-table :test 'equal)) + (reps (make-hash-table :test 'equal))) (dolist (it issues) (let ((label (plist-get it :label))) - (unless (gethash label table) (push label order)) + (unless (gethash label table) + (push label order) + (puthash label (plist-get it :rep) reps)) (push it (gethash label table)))) - (dolist (label (nreverse order)) - (insert (format "** %s\n" label)) - (dolist (it (nreverse (gethash label table))) - (insert (pearl--shift-heading-levels (plist-get it :text) 1))))) + ;; Order the sections the same way the render path does: a + ;; one-rep group per label fed through `pearl--order-groups'. + (let ((ordered (pearl--order-groups + grouping + (mapcar (lambda (l) (cons l (list (gethash l reps)))) + (nreverse order))))) + (dolist (g ordered) + (let ((label (car g))) + (insert (format "** %s\n" label)) + (dolist (it (nreverse (gethash label table))) + (insert (pearl--shift-heading-levels (plist-get it :text) 1))))))) (dolist (it issues) (insert (plist-get it :text)))) (dolist (m malformed) (insert m)) diff --git a/tests/test-pearl-grouping.el b/tests/test-pearl-grouping.el index a30bcc1..5bcae1d 100644 --- a/tests/test-pearl-grouping.el +++ b/tests/test-pearl-grouping.el @@ -354,5 +354,63 @@ SE-1 has a comment subtree; SE-2 has no assignee.") (should (string-match-p "^\\*\\* Apollo$" out)) (should-not (string-match-p "^\\*\\* In Progress$" out)))) +;;; group ordering (board order, not first-appearance) + +(ert-deftest test-pearl-order-groups-workflowstate-by-type-then-position () + "workflowState groups order by state type rank, then position within a type." + (let ((groups (list (cons "In Progress" (list '(:state (:type "started" :position 3.0)))) + (cons "Icebox" (list '(:state (:type "backlog" :position 1.0)))) + (cons "Done" (list '(:state (:type "completed" :position 5.0)))) + (cons "Inbox" (list '(:state (:type "backlog" :position 2.0))))))) + (should (equal (mapcar #'car (pearl--order-groups "workflowState" groups)) + '("Icebox" "Inbox" "In Progress" "Done"))))) + +(ert-deftest test-pearl-order-groups-priority-urgent-first-none-last () + "priority groups order Urgent..Low, then None last." + (let ((groups (list (cons "None" (list '(:priority 0))) + (cons "High" (list '(:priority 2))) + (cons "Urgent" (list '(:priority 1))) + (cons "Low" (list '(:priority 4)))))) + (should (equal (mapcar #'car (pearl--order-groups "priority" groups)) + '("Urgent" "High" "Low" "None"))))) + +(ert-deftest test-pearl-order-groups-other-dimension-alphabetical () + "A dimension with no board order (project) sorts alphabetically by label." + (let ((groups (list (cons "Zeta" (list '(:project (:name "Zeta")))) + (cons "alpha" (list '(:project (:name "alpha"))))))) + (should (equal (mapcar #'car (pearl--order-groups "project" groups)) + '("alpha" "Zeta"))))) + +(ert-deftest test-pearl-group-issues-orders-by-board-order () + "pearl--group-issues sections by board order even when input order differs." + (let ((issues (list '(:id "a" :state (:name "In Progress" :type "started" :position 3.0)) + '(:id "b" :state (:name "Icebox" :type "backlog" :position 1.0))))) + (should (equal (mapcar #'car (pearl--group-issues issues "workflowState")) + '("Icebox" "In Progress"))))) + +(defconst test-pearl-grouping--positioned + (concat + "#+LINEAR-SOURCE: (:type filter :name \"F\" :filter (:open t))\n\n" + "* F\n" + "** TODO SE-1: Alpha\n:PROPERTIES:\n:LINEAR-ID: i1\n" + ":LINEAR-STATE-NAME: In Progress\n:LINEAR-STATE-TYPE: started\n:LINEAR-STATE-POSITION: 3.0\n:END:\nBody.\n" + "** TODO SE-2: Beta\n:PROPERTIES:\n:LINEAR-ID: i2\n" + ":LINEAR-STATE-NAME: Icebox\n:LINEAR-STATE-TYPE: backlog\n:LINEAR-STATE-POSITION: 1.0\n:END:\nBody.\n") + "Flat buffer where first-appearance (In Progress, Icebox) differs from board +order (Icebox before In Progress).") + +(ert-deftest test-pearl-regroup-orders-sections-by-board-order () + "Regrouping by status orders the sections by Linear's board order, not arrival." + (test-pearl-grouping--in-org test-pearl-grouping--positioned + (should (eq (pearl--regroup-issue-subtrees "workflowState") t)) + (should (equal (test-pearl-grouping--group-headings) '("Icebox" "In Progress"))))) + +(ert-deftest test-pearl-format-issue-renders-state-position () + "The issue drawer carries LINEAR-STATE-POSITION so an offline regroup can order." + (let ((out (pearl--format-issue-as-org-entry + '(:id "x" :identifier "SE-9" :title "T" + :state (:name "S" :type "started" :position 2.5))))) + (should (string-match-p "LINEAR-STATE-POSITION:[[:space:]]+2\\.5" out)))) + (provide 'test-pearl-grouping) ;;; test-pearl-grouping.el ends here diff --git a/tests/test-pearl-output.el b/tests/test-pearl-output.el index 55f7886..1035ff2 100644 --- a/tests/test-pearl-output.el +++ b/tests/test-pearl-output.el @@ -90,7 +90,7 @@ cookie cycling reaches Low regardless of the user's global `org-priority-lowest' (ert-deftest test-pearl-build-org-content-grouped-sections-and-startup () "A grouped view folds to show3levels, emits a level-2 group heading per group -in first-appearance order, and renders issues at level 3 under them." +in Linear board order (state type rank), and renders issues at level 3 under them." (let* ((issues '((:id "1" :identifier "SE-1" :title "One" :state (:name "Started" :type "started") :priority 0) (:id "2" :identifier "SE-2" :title "Two" @@ -104,14 +104,16 @@ in first-appearance order, and renders issues at level 3 under them." (should (string-match-p "^\\*\\* Backlog$" out)) ;; Issues live at level 3 under the group headings. (should (string-match-p "^\\*\\*\\* STARTED " out)) - ;; First-appearance order: SE-1 (Started) before SE-2 (Backlog). - (should (< (string-match "^\\*\\* Started$" out) - (string-match "^\\*\\* Backlog$" out))) - ;; The group heading carries no LINEAR-ID, so save/merge skip it. - (let ((started (string-match "^\\*\\* Started$" out)) + ;; Board order: Backlog (backlog-type) sorts before Started (started-type), + ;; regardless of which appeared first in the input. + (should (< (string-match "^\\*\\* Backlog$" out) + (string-match "^\\*\\* Started$" out))) + ;; The first group heading carries no LINEAR-ID, so save/merge skip it: it + ;; sits ahead of the first issue drawer. + (let ((first-group (string-match "^\\*\\* Backlog$" out)) (next-id (string-match "LINEAR-ID:" out))) - (should started) - (should (< started next-id))))) + (should first-group) + (should (< first-group next-id))))) (ert-deftest test-pearl-build-org-content-grouped-source-roundtrips () "A grouped view's source (with :group) reads back from the rendered header." |
