Repository: LuteOrg/lute-v3 (language: python)

Issue:
Status stats: term status statistics UI
New column in the booktable showing term status statistics.
The three default themes have been adjusted for colors.

![image](https://github.com/jzohrab/lute-v3/assets/70017511/4b33fe22-e8b9-4ccd-9103-89df62d6b80b)



Discussion / hints:

Fix patch:
diff --git a/lute/static/css/styles.css b/lute/static/css/styles.css
index 3fd73bdf..e1e44b2a 100644
--- a/lute/static/css/styles.css
+++ b/lute/static/css/styles.css
@@ -1478,3 +1478,66 @@ input[type="checkbox"][disabled] + label {
         width: 100% !important;
     }
 }
+
+
+/* status stats */
+
+.status-bar-container {
+    display: inline-flex;
+    align-items: center;
+    justify-content: center;
+    gap: 1px;
+    width: 100%;
+    border-radius: 3px;
+    box-sizing: border-box;
+    background-color: #787878;
+    border: 1px solid #787878;
+}
+
+.status-bar-container-empty {
+    border-color: #e6e6e6;
+    background-color: #fff;
+}
+
+.status-bar {
+    display: flex;
+    align-items: center;
+    height: 10px;
+    font-size: 0.8rem;
+    box-sizing: border-box;
+    /* saturate the colors  */
+    filter: saturate(2.4) brightness(0.95); 
+}
+
+.status-bar0 {
+    background-color: var(--status-0-color);
+}
+
+.status-bar1 {
+    background-color: var(--status-1-color);
+}
+
+.status-bar2 {
+    background-color: var(--status-2-color);
+}
+
+.status-bar3 {
+    background-color: var(--status-3-color);
+}
+
+.status-bar4 {
+    background-color: var(--status-4-color);
+}
+
+.status-bar5 {
+    background-color: var(--status-5-color);
+}
+
+.status-bar99 {
+    background-color: var(--status-99-color);
+    filter: saturate(1.4);
+}
+
+#booktable td {
+    vertical-align: middle;
+}
diff --git a/lute/templates/book/tablelisting.html b/lute/templates/book/tablelisting.html
index ab9a6a8e..95b80932 100644
--- a/lute/templates/book/tablelisting.html
+++ b/lute/templates/book/tablelisting.html
@@ -1,10 +1,11 @@
 <table id="booktable" class="table dataTable stripe">
   <thead>
     <tr>
-      <th width="40%">Title</th>
+      <th>Title</th>
       <th>Language</th>
       <th>Tags</th>
       <th>Word count (% known)</th>
+      <th>Statuses</th>
       <th>Actions</th>
     </tr>
   </thead>
@@ -38,81 +39,24 @@
       stateSave: true,
       search: { search: initial_search },
       columnDefs: [
-        {
-          "name": "BkTitle", "targets": 0,
-          "render": function ( data, type, row, meta ) {
-            const bkid = parseInt(row[3]);
-            const pgnum = parseInt(row[10]);
-            const pgcount = parseInt(row[8]);
-            let pgfraction = '';
-
-            const completed = (parseInt(row[11]) == 1);
-            let book_title_classes = ['book-title'];
-            if (completed) {
-              book_title_classes.push('completed_book');
-            }
-            else if (pgnum > 1) {
-              pgfraction = ` (${pgnum}/${pgcount})`;
-            }
-
-            return `<a class="${book_title_classes.join(' ')}" href="/read/${bkid}">${row[0]}${pgfraction}</a>`;
-          }
-        },
-        { "name": "LgName", "targets": 1 },
-        { "name": "TagList", "targets": 2 },
-
-        { "targets": 3,
-          "data": null,
-          "searchable": false,
-          "orderable": false,
-          "render": function ( data, type, row, meta ) {
-            let ret = row[5];
-            const pct = row[7];
-            const unkcount = row[6];
-            if (pct != null) {
-              const termcount = row[9];
-              const knownpct = 100 - parseInt(pct);
-              ret += `&nbsp;<span title="${unkcount} unknowns in next ${termcount} unique terms">(${knownpct}%)</span>`;
-            }
-            // TODO issue 102 status graph: render graph of distribution.
-            ret += `<div style="display: none">${row[12]}</div>`;
-            return ret;
-          }
-        },
-        {
-          "targets": 4,
-          "data": null,
-          "searchable": false,
-          "orderable": false,
-          "render": function ( data, type, row, meta ) {
-            // TODO zzfuture fix: security - add CSRF token
-            const ret = [];
-            const bkid = row[3];
-            const bktitle = encodeURIComponent(row[0]);
-            if (row[4] == 0) {
-              // not archived
-              ret.push(`<img src="{{ url_for('static', filename='icn/document--pencil.png') }}" title="Edit" onclick="edit_book(${bkid})" />`);
-              ret.push(`<img src="{{ url_for('static', filename='icn/inbox-download.png') }}" title="Archive" onclick="confirm_archive(${bkid})" />`);
-            }
-            else {
-              ret.push(`<img src="{{ url_for('static', filename='icn/inbox-upload.png') }}" title="Unarchive" onclick="confirm_unarchive(${bkid})" />`);
-            }
-            ret.push(`<img src="{{ url_for('static', filename='icn/minus-button.png') }}" title="Delete" data-bktitle="${bktitle}" data-bkid="${bkid}" onclick="confirm_delete(this)" />`);
-            return ret.join('&nbsp;');
-          }
-        },
+        { "name": "BkTitle", "targets": 0, "width": "40%", "render": render_book_title },
+        { "name": "LgName", "targets": 1, "width": "10%" },
+        { "name": "TagList", "targets": 2, "width": "10%" },
+        { "targets": 3, "data": null, "width": "15%", "searchable": false, "orderable": false, "render": render_book_word_count },
+        { "targets": 4, "data": null, "searchable": false, "orderable": false, "render": render_book_stats_graph },
+        { "targets": 5, "data": null, "width": "8%", "searchable": false, "orderable": false, "render": render_book_actions },
 
         /* Extra data that is returned in the row for rendering, but not shown. */
-        { "name": "BkID", "targets": 5, "data": null, "visible": false },
-        { "name": "BkArchived", "targets": 6, "data": null, "visible": false },
-        { "name": "WordCount", "targets": 7, "data": null, "visible": false },
-        { "name": "UnknownCount", "targets": 8, "data": null, "visible": false },
-        { "name": "UnknownPercent", "targets": 9, "data": null, "visible": false },
-        { "name": "PageCount", "targets": 10, "data": null, "visible": false },
-        { "name": "DistinctCount", "targets": 11, "data": null, "visible": false },
-        { "name": "PageNum", "targets": 12, "data": null, "visible": false },
-        { "name": "IsCompleted", "targets": 13, "data": null, "visible": false },
-        { "name": "StatusDistribution", "targets": 14, "data": null, "visible": false },
+        { "name": "BkID", "targets": 6, "data": null, "visible": false },
+        { "name": "BkArchived", "targets": 7, "data": null, "visible": false },
+        { "name": "WordCount", "targets": 8, "data": null, "visible": false },
+        { "name": "UnknownCount", "targets": 9, "data": null, "visible": false },
+        { "name": "UnknownPercent", "targets": 10, "data": null, "visible": false },
+        { "name": "PageCount", "targets": 11, "data": null, "visible": false },
+        { "name": "DistinctCount", "targets": 12, "data": null, "visible": false },
+        { "name": "PageNum", "targets": 13, "data": null, "visible": false },
+        { "name": "IsCompleted", "targets": 14, "data": null, "visible": false },
+        { "name": "StatusDistribution", "targets": 15, "data": null, "visible": false },
       ],
 
       // Ajax call
@@ -165,6 +109,101 @@
     do_action_post('delete', bookid);
   }
 
+
+  let render_book_title = function ( data, type, row, meta ) {
+    const bkid = parseInt(row[3]);
+    const pgnum = parseInt(row[10]);
+    const pgcount = parseInt(row[8]);
+    let pgfraction = '';
+
+    const completed = (parseInt(row[11]) == 1);
+    let book_title_classes = ['book-title'];
+    if (completed) {
+      book_title_classes.push('completed_book');
+    }
+    else if (pgnum > 1) {
+      pgfraction = ` (${pgnum}/${pgcount})`;
+    }
+
+    return `<a class="${book_title_classes.join(' ')}" href="/read/${bkid}">${row[0]}${pgfraction}</a>`;
+  };
+
+
+  let render_book_word_count = function ( data, type, row, meta ) {
+    let ret = row[5];
+    const pct = row[7];
+    const unkcount = row[6];
+    if (pct != null) {
+      const termcount = row[9];
+      const knownpct = 100 - parseInt(pct);
+      ret += `&nbsp;<span title="${unkcount} unknowns in next ${termcount} unique terms">(${knownpct}%)</span>`;
+    }
+    return ret;
+  };
+
+
+  let render_book_stats_graph = function(data, type, row, meta) {
+    let statuscounts = row[12];
+    if ((statuscounts ?? '')== '') {
+      return "";
+    }
+    statuscounts = JSON.parse(statuscounts);
+    statuscounts["99"] = statuscounts["98"] + statuscounts["99"];
+    delete statuscounts['98'];
+    const totalcount = Object.values(statuscounts).reduce((acc, val) => acc + val, 0);
+    if (totalcount == 0) {
+      return "";
+    }
+    const statuspct = {};
+    Object.entries(statuscounts).forEach(([key, value]) => {
+      let pct = (value * 100.0) / totalcount;
+      statuspct[key] = pct.toFixed(0);
+    });
+    // return JSON.stringify(statuspct);
+
+    let make_bar = function(stid, title) {
+      const p = statuspct[stid];
+      if (p < 1)
+        return '';
+      return `<div
+        class="status-bar${stid} status-bar"
+        title="${title}: ${p}%"
+        style="flex: ${p}; display: inline-flex"
+        ></div>`;
+    };
+
+    const bar_titles = {
+      "0": "Unknown",
+      "1": 1, "2": 2, "3": 3, "4": 4, "5": 5,
+      "99": "Well Known or Ignored"
+    };
+    ret = `<div class="status-bar-container">`;
+    Object.entries(bar_titles).forEach(([key, title]) => {
+      ret += make_bar(key, title);
+    });
+    ret += `</div>`;
+    return ret;
+  };
+
+
+  let render_book_actions = function ( data, type, row, meta ) {
+    // TODO zzfuture fix: security - add CSRF token
+    const ret = [];
+    const bkid = row[3];
+    const bktitle = encodeURIComponent(row[0]);
+    if (row[4] == 0) {
+      // not archived
+      ret.push(`<img src="{{ url_for('static', filename='icn/document--pencil.png') }}" title="Edit" onclick="edit_book(${bkid})" />`);
+      ret.push(`<img src="{{ url_for('static', filename='icn/inbox-download.png') }}" title="Archive" onclick="confirm_archive(${bkid})" />`);
+    }
+    else {
+      ret.push(`<img src="{{ url_for('static', filename='icn/inbox-upload.png') }}" title="Unarchive" onclick="confirm_unarchive(${bkid})" />`);
+    }
+    ret.push(`<img src="{{ url_for('static', filename='icn/minus-button.png') }}" title="Delete" data-bktitle="${bktitle}" data-bkid="${bkid}" onclick="confirm_delete(this)" />`);
+    return ret.join('&nbsp;');
+  };
+
+
   /**
    * Clearing the state is required for acceptance tests,
    * because otherwise state is accidentally applied to


Test patch:
diff --git a/tests/acceptance/book.feature b/tests/acceptance/book.feature
index 1e7f55e1..3600b86b 100644
--- a/tests/acceptance/book.feature
+++ b/tests/acceptance/book.feature
@@ -65,7 +65,7 @@ Feature: Books and stats are available
         Given I visit "/"
         When I set the book table filter to "Hola"
         Then the book table contains:
-            Hola; Spanish; ; 4 (0%);
+            Hola; Spanish; ; 4 (0%); ;
 
     # Dealing with production bug.
     Scenario: Japanese book with multiple paragraphs works.
diff --git a/tests/acceptance/unsupported_parser.feature b/tests/acceptance/unsupported_parser.feature
index b621dfba..2c236e71 100644
--- a/tests/acceptance/unsupported_parser.feature
+++ b/tests/acceptance/unsupported_parser.feature
@@ -23,7 +23,7 @@ Feature: Unsupported language data is hidden
 
         When I set the book table filter to "Hola"
         Then the book table contains:
-            Hola; Spanish; ; 4 (25%);
+            Hola; Spanish; ; 4 (25%); ;
         Then the term table contains:
             ; gato; ; cat; Spanish; ; New (1)
 
@@ -36,8 +36,8 @@ Feature: Unsupported language data is hidden
 
         When I set the book table filter to "Hola"
         Then the book table contains:
-            Hola; Japanese; ; 1 (0%);
-            Hola; Spanish; ; 4 (25%);
+            Hola; Japanese; ; 1 (0%); ;
+            Hola; Spanish; ; 4 (25%); ;
         Then the term table contains:
             ; gato; ; cat; Japanese; ; New (1)
             ; gato; ; cat; Spanish; ; New (1)