83858083785a6b2b1f3c05f93e818e5a89318692
max
  Tue Aug 25 01:42:34 2026 -0700
hgSession: show cytoBand and gene/locus annotations for each saved session; date-only Created/Last used columns with full timestamp on hover. Factor locusName lookup into reusable hLocusName/hLocusNameExpand in hdb.c and reuse from hgBlat. refs #38157

The Assembly column now shows band, gene/locus name and Mbp position (separated by whitespace, normal text size). hLocusNameExpand centralizes the ex:/in:/ig: and | to - expansion that hgBlat's results page did inline.

diff --git src/hg/js/hgSession.js src/hg/js/hgSession.js
index 090936a3cb4..cc2942efb23 100644
--- src/hg/js/hgSession.js
+++ src/hg/js/hgSession.js
@@ -46,35 +46,40 @@
     return (typeof commify === 'function') ? commify(n) : String(n);
 }
 
 function sessRandomShareName() {
     // Mirror the server's auto/anonymous share-name convention ("share_" + 8 URL-safe alphanumeric
     // chars).  hgSession.c's doSaveSessionJson generates the same style server-side for the top-right
     // "Share a link"; we generate it here so the confirm dialog can show the name before saving.
     var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
     var s = '';
     for (var i = 0; i < 8; i++) {
         s += chars.charAt(Math.floor(Math.random() * chars.length));
     }
     return 'share_' + s;
 }
 
-function sessCommifyPos(pos) {
-    // Add thousands separators to each number in a position string ("chr7:155799529-155812871" ->
-    // "chr7:155,799,529-155,812,871") using utils.js commify.
-    if (typeof commify !== 'function') { return String(pos); }
-    return String(pos).replace(/\d+/g, function(n) { return commify(n); });
+function sessMbpPos(pos) {
+    // Shorten a position to megabases to save horizontal space:
+    // "chr7:155799529-155812871" -> "chr7:155.80-155.81 Mbp".  Falls back to the raw string
+    // if it doesn't parse.
+    var m = /^(.+):([0-9,]+)-([0-9,]+)$/.exec(String(pos));
+    if (!m) { return String(pos); }
+    var s = parseInt(m[2].replace(/,/g, ''), 10);
+    var e = parseInt(m[3].replace(/,/g, ''), 10);
+    if (isNaN(s) || isNaN(e)) { return String(pos); }
+    return m[1] + ':' + (s / 1e6).toFixed(2) + '-' + (e / 1e6).toFixed(2) + ' Mbp';
 }
 
 function sessMsg(text, cls) {
     // Show a transient status line at the top of the app (cls: 'ok' | 'err' | '').
     var el = document.getElementById('sessMsg');
     if (!el) { return; }
     el.className = 'sessMsg' + (cls ? ' ' + cls : '');
     el.innerHTML = text ? sessEnc(text) : '';
 }
 
 // ---- AJAX ----------------------------------------------------------------
 // Post an action to hgSession and get JSON back.  Always carry the session id so the CGI loads the
 // right cart.  onOk(resp) is called for {success:true,...} or {name,url}; onErr(msg) for {error}.
 
 function sessAjax(params, onOk, onErr) {
@@ -715,41 +720,49 @@
         language: { searchPlaceholder: 'Search sessions', search: '', lengthMenu: 'Show _MENU_',
                     emptyTable: 'No saved sessions yet.' },
         columns: [
             { title: '<input type="checkbox" id="sessSelAll" title="Select all on this page">',
               className: 'sessSelCol', data: null, orderable: false, searchable: false, visible: false,
               render: function(d, type, row) {
                   return '<input type="checkbox" class="sessSelChk" data-enc="' +
                       sessEnc(row.encName) + '">'; } },
             { title: 'Session', className: 'sessNameCol', data: 'name',
               render: function(d, type, row) {
                   return (type === 'display') ? sessNameCellHtml(row) : row.name; } },
             { title: 'Assembly', data: 'db',
               render: function(d, type, row) {
                   if (type !== 'display') { return row.db || ''; }
                   var html = sessEnc(row.db || 'n/a');
-                  if (row.position) {
-                      html += ' <span class="sessPos">' + sessEnc(sessCommifyPos(row.position)) +
-                          '</span>';
+                  // Region: band, locus (gene) and the position shortened to Mbp, so it all fits.
+                  var region = [];
+                  if (row.band) { region.push(sessEnc(row.band)); }
+                  if (row.locus) { region.push(sessEnc(row.locus)); }
+                  if (row.position) { region.push(sessEnc(sessMbpPos(row.position))); }
+                  if (region.length) {
+                      html += ' <span class="sessPos">' + region.join('&nbsp;&nbsp; ') + '</span>';
                   }
                   return html; } },
             { title: 'Created', data: 'createdEpoch',
               render: function(d, type, row) {
-                  return (type === 'display') ? sessEnc(row.created) : row.createdEpoch; } },
+                  return (type === 'display') ?
+                      '<span title="' + sessEnc(row.createdFull || row.created) + '">' +
+                      sessEnc(row.created) + '</span>' : row.createdEpoch; } },
             { title: 'Last used', data: 'lastUseEpoch',
               render: function(d, type, row) {
-                  return (type === 'display') ? sessEnc(row.lastUse) : row.lastUseEpoch; } },
+                  return (type === 'display') ?
+                      '<span title="' + sessEnc(row.lastUse) + '">' +
+                      sessEnc(row.lastUseDate || row.lastUse) + '</span>' : row.lastUseEpoch; } },
             { title: 'Views', data: 'useCount',
               render: function(d, type) { return (type === 'display') ? sessNum(d) : d; } },
             { title: 'Actions', className: 'sessActionsCol', data: null, orderable: false,
               searchable: false,
               render: function(d, type, row) { return (type === 'display') ? sessActionsHtml(row) : ''; } }
         ]
     });
 
     // Delegate the row action buttons.
     $('#sessionAppTable tbody').on('click', 'button[data-act]', function() {
         var act = this.getAttribute('data-act');
         var row = sessByEnc(this.getAttribute('data-enc'));
         if (!row) { return; }
         if (act === 'overwrite') { sessOpenOverwrite(row); }
         else if (act === 'share') { sessOpenShare(row); }