[object Object]

← back to AbramsOS

Add priority ranking + per-item ratings view to settlement-claims dashboard

ad64537f27d9ca96af03892e0b0df301c417386a · 2026-08-18 14:38:27 -0700 · steve

Mirror the :9801 approvals scoreGate engine on the AbramsOS settlements page:
per-claim 0-5 ratings (value/urgency/ease/win-likelihood), composite priority
(value*2.4 + urgency*2.4 + ease*1.0 + win*0.4), high/med/low tier. Rank open
claims priority-first with rank badge, tier chip, rating bars, $/deadline/proof
pills; show recently-expired separately. New lib/settlement-score.js (pure/
deterministic); scores computed server-side in the route, passed to the EJS view.
Eligibility controls + fill-not-submit staging button unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit ad64537f27d9ca96af03892e0b0df301c417386a
Author: steve <steve@designerwallcoverings.com>
Date:   Tue Aug 18 14:38:27 2026 -0700

    Add priority ranking + per-item ratings view to settlement-claims dashboard
    
    Mirror the :9801 approvals scoreGate engine on the AbramsOS settlements page:
    per-claim 0-5 ratings (value/urgency/ease/win-likelihood), composite priority
    (value*2.4 + urgency*2.4 + ease*1.0 + win*0.4), high/med/low tier. Rank open
    claims priority-first with rank badge, tier chip, rating bars, $/deadline/proof
    pills; show recently-expired separately. New lib/settlement-score.js (pure/
    deterministic); scores computed server-side in the route, passed to the EJS view.
    Eligibility controls + fill-not-submit staging button unchanged.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib/settlement-score.js | 108 +++++++++++++++++++++++++++++++++++++++
 routes/settlements.js   |   9 +++-
 views/settlements.ejs   | 133 ++++++++++++++++++++++++++++++++++++------------
 3 files changed, 216 insertions(+), 34 deletions(-)

diff --git a/lib/settlement-score.js b/lib/settlement-score.js
new file mode 100644
index 0000000..f9fc9d1
--- /dev/null
+++ b/lib/settlement-score.js
@@ -0,0 +1,108 @@
+// lib/settlement-score.js — priority RANKING + per-item RATINGS for settlement claims.
+//
+// Mirrors the gated-queue-runner `scoreGate` engine (:9801 approvals viewer), but reads the
+// STRUCTURED settlement_claim columns instead of parsing a memo body. For each claim it computes
+// four 0-5 ratings + a composite priority + a high/med/low tier, then ranks claims priority-first.
+//
+// Ratings (0-5):
+//   value          — from payout_max_cents ($ scale)
+//   urgency        — days to deadline
+//   ease           — auto/no-proof flat-cash is easy; proof lowers it
+//   winlikelihood  — no-proof flat-cash = high; proof = lower
+// priority = value*2.4 + urgency*2.4 + ease*1.0 + winlikelihood*0.4
+// tier: high >= 26, med >= 18, else low
+//
+// Pure + deterministic. No DB, no network — takes a plain claim row, returns the scored fields.
+
+// pg DATE columns come back as JS Date objects (not strings); handle both, compare calendar
+// days at local midnight so 0 = due today, 1 = tomorrow, negative = expired.
+function daysToDeadline(deadline) {
+  if (deadline == null) return null;
+  const dt = deadline instanceof Date ? deadline : new Date(String(deadline) + 'T00:00:00');
+  if (isNaN(dt.getTime())) return null;
+  const target = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()).getTime();
+  const now = new Date();
+  const todayMid = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
+  return Math.round((target - todayMid) / 86400000);
+}
+
+// A claim is "easy / high win" when no claim is required (automatic), or when the payout is a
+// flat cash amount with no proof needed. Proof requirements lower both ease and win-likelihood.
+function isFlatCash(payoutText) {
+  return /\b(no proof|flat|automatic|no claim|cash|guaranteed)\b/i.test(String(payoutText || ''));
+}
+
+function scoreClaim(claim) {
+  const cents = claim.payout_max_cents == null ? 0 : Number(claim.payout_max_cents);
+  const dollars = cents / 100;
+  const days = daysToDeadline(claim.deadline);
+  const proof = claim.proof_required === true;
+  const auto = claim.no_claim_required === true;
+  const flat = isFlatCash(claim.payout_text);
+
+  // 💰 value: dollar scale
+  const value =
+    dollars >= 10000 ? 5 :
+    dollars >= 5000  ? 4 :
+    dollars >= 1000  ? 3 :
+    dollars >= 100   ? 2 :
+    dollars > 0      ? 1 : 0;
+
+  // ⏰ urgency: days to deadline (null deadline = low urgency)
+  const urgency =
+    days == null ? 1 :
+    days <= 1  ? 5 :
+    days <= 3  ? 4 :
+    days <= 7  ? 3 :
+    days <= 30 ? 2 : 1;
+
+  // ⚡ ease: automatic / no-proof flat-cash = easiest; proof required lowers it.
+  let ease = 3;
+  if (auto) ease = 5;
+  else if (!proof && flat) ease = 5;
+  else if (!proof) ease = 4;
+  if (proof) ease -= 2;
+  ease = Math.max(0, Math.min(5, ease));
+
+  // 🎯 winlikelihood: no-proof flat-cash / automatic = high; proof-required = lower.
+  let win = 3;
+  if (auto) win = 5;
+  else if (!proof && flat) win = 5;
+  else if (!proof) win = 4;
+  if (proof) win -= 2;
+  win = Math.max(0, Math.min(5, win));
+
+  const priority = Math.round((value * 2.4 + urgency * 2.4 + ease * 1.0 + win * 0.4) * 10) / 10;
+  const tier = priority >= 26 ? 'high' : priority >= 18 ? 'med' : 'low';
+
+  return {
+    ratings: { value, urgency, ease, winlikelihood: win },
+    priority,
+    tier,
+    days,
+    dollars,
+    expired: days != null && days < 0,
+  };
+}
+
+// Score a list of claims, split into open (deadline >= today OR null) and recently-expired,
+// then rank the open set by priority desc (tie-break: sooner deadline, then bigger payout).
+function rankClaims(rows) {
+  const scored = rows.map((r) => ({ ...r, ...scoreClaim(r) }));
+  const open = scored.filter((s) => !s.expired);
+  const expired = scored.filter((s) => s.expired);
+
+  open.sort((a, b) =>
+    b.priority - a.priority ||
+    (a.days == null ? Infinity : a.days) - (b.days == null ? Infinity : b.days) ||
+    (Number(b.payout_max_cents) || 0) - (Number(a.payout_max_cents) || 0)
+  );
+  open.forEach((s, i) => { s.rank = i + 1; });
+
+  // expired sorted most-recently-lapsed first
+  expired.sort((a, b) => (b.days ?? -Infinity) - (a.days ?? -Infinity));
+
+  return { open, expired };
+}
+
+module.exports = { scoreClaim, rankClaims, daysToDeadline };
diff --git a/routes/settlements.js b/routes/settlements.js
index 85c7eaf..59375b2 100644
--- a/routes/settlements.js
+++ b/routes/settlements.js
@@ -6,6 +6,7 @@ const fs = require('fs');
 const path = require('path');
 const db = require('../lib/db');
 const filler = require('../lib/openclaw-claim-filler');
+const { rankClaims } = require('../lib/settlement-score');
 
 const router = express.Router();
 const USER = 'user_steve';
@@ -29,12 +30,16 @@ async function loadRows() {
 
 router.get('/settlements', async (_req, res) => {
   const { source, rows } = await loadRows();
-  res.render('settlements', { rows, source });
+  // Compute per-item ratings + composite priority server-side, split open vs recently-expired,
+  // rank the open set priority-first. The view renders ranks/tiers/bars off these fields.
+  const { open, expired } = rankClaims(rows);
+  res.render('settlements', { open, expired, source });
 });
 
 router.get('/api/settlements', async (_req, res) => {
   const { source, rows } = await loadRows();
-  res.json({ source, count: rows.length, rows });
+  const { open, expired } = rankClaims(rows);
+  res.json({ source, open, expired, count: open.length, expired_count: expired.length });
 });
 
 // Steve's eligibility decision — the ONLY thing that unlocks a fill.
diff --git a/views/settlements.ejs b/views/settlements.ejs
index d467fe6..cf5c639 100644
--- a/views/settlements.ejs
+++ b/views/settlements.ejs
@@ -1,51 +1,120 @@
 <%- include('partials/header', { title: 'Settlements' }) %>
+<%
+  // ---- render helpers (server data already carries ratings/priority/tier/rank) ----
+  var TIER = { high:{dot:'🔴',label:'HIGH',color:'#c0392b'}, med:{dot:'🟡',label:'MED',color:'#b8860b'}, low:{dot:'⚪',label:'LOW',color:'#8894a5'} };
+  function money(c){ return c==null ? '—' : '$'+(c/100).toLocaleString('en-US'); }
+  function proofLabel(s){ return s.no_claim_required ? 'AUTO' : (s.proof_required===false ? 'no proof' : (s.proof_required ? 'proof' : '?')); }
+  function deadlinePill(s){
+    if (s.days==null) return 'no deadline';
+    if (s.days<0) return 'expired '+(-s.days)+'d ago';
+    if (s.days===0) return 'DUE TODAY';
+    if (s.days===1) return 'due tomorrow';
+    return 'in '+s.days+'d';
+  }
+  // a labelled 0-5 rating bar
+  function bar(label, v){
+    var pct = Math.round((v/5)*100);
+    var col = v>=4 ? '#3fb950' : v>=2 ? '#d29922' : '#8894a5';
+    return '<div class="rbar" title="'+label+' '+v+'/5">'
+      + '<span class="rbar-l">'+label+'</span>'
+      + '<span class="rbar-track"><span class="rbar-fill" style="width:'+pct+'%;background:'+col+'"></span></span>'
+      + '<span class="rbar-v">'+v+'</span></div>';
+  }
+%>
+<style>
+  .claim-card{border:1px solid rgba(255,255,255,.08);border-radius:10px;padding:14px 16px;margin:10px 0;display:grid;grid-template-columns:44px 1fr 300px;gap:14px;align-items:start}
+  .claim-card.expired{opacity:.5}
+  .rank-badge{display:flex;flex-direction:column;align-items:center;justify-content:center;width:44px;height:44px;border-radius:10px;background:rgba(255,255,255,.05);font-weight:700}
+  .rank-badge .n{font-size:18px;line-height:1}
+  .rank-badge .p{font-size:9px;color:var(--text-dim);margin-top:2px}
+  .tier-chip{display:inline-flex;align-items:center;gap:4px;font-size:10px;font-weight:700;padding:2px 7px;border-radius:20px;border:1px solid currentColor}
+  .pill{display:inline-block;font-size:11px;padding:2px 8px;border-radius:20px;background:rgba(255,255,255,.06);margin-right:6px}
+  .pill.due{color:#f0b429}.pill.today{color:#ff6b6b;font-weight:700}.pill.past{color:#8894a5}
+  .rbar{display:flex;align-items:center;gap:6px;margin:3px 0;font-size:11px}
+  .rbar-l{width:78px;color:var(--text-dim)}
+  .rbar-track{flex:1;height:6px;border-radius:4px;background:rgba(255,255,255,.08);overflow:hidden}
+  .rbar-fill{display:block;height:100%}
+  .rbar-v{width:14px;text-align:right;color:var(--text-dim)}
+  .claim-name{font-weight:600;font-size:14px}
+  .claim-meta{color:var(--text-dim);font-size:11px;margin-top:2px}
+  .ctrls{display:flex;flex-direction:column;gap:8px}
+</style>
+
 <section class="page-head">
   <h1>Settlement Claims</h1>
   <p class="subtle" style="margin:0;color:var(--text-dim);font-size:13px">
-    Auto-collected from the <strong>Mode Class Actions</strong> feed. Nothing is filed automatically.
+    Auto-collected from the <strong>Mode Class Actions</strong> feed, ranked by <strong>priority</strong>
+    (value · urgency · ease · win-likelihood). Nothing is filed automatically.
     Mark <em>Eligible</em> only for settlements you're truly a class member of — then openclaw prefills the
     form and <strong>stops at the perjury attestation for you to submit</strong>.
     <% if (source !== 'db') { %><span style="color:#e0a030">· showing parsed backfill (DB offline — mutations disabled)</span><% } %>
   </p>
 </section>
-<section class="glass" style="padding:0;overflow:auto">
-  <table style="width:100%;border-collapse:collapse;font-size:13px">
-    <thead><tr style="text-align:left;color:var(--text-dim);border-bottom:1px solid rgba(255,255,255,.08)">
-      <th style="padding:10px 12px">Settlement</th><th>Max</th><th>Deadline</th><th>Proof</th>
-      <th>Eligibility test</th><th>You</th><th>Fill</th>
-    </tr></thead>
-    <tbody>
-    <% rows.forEach(function(s){
-       var money = s.payout_max_cents==null ? '—' : '$'+(s.payout_max_cents/100).toLocaleString('en-US');
-       var proof = s.no_claim_required ? 'AUTO' : (s.proof_required===false ? 'no proof' : (s.proof_required ? 'proof' : '?'));
-       var es = s.eligibility_state||'unreviewed';
-    %>
-      <tr data-id="<%= s.id %>" style="border-bottom:1px solid rgba(255,255,255,.05)<%= s.fill_state==='expired'?';opacity:.45':'' %>">
-        <td style="padding:10px 12px">
+
+<section>
+  <% if (!open.length) { %>
+    <p class="subtle" style="color:var(--text-dim)">No open claims.</p>
+  <% } %>
+  <% open.forEach(function(s){ var t = TIER[s.tier] || TIER.low; %>
+    <div class="claim-card" data-id="<%= s.id %>">
+      <div class="rank-badge">
+        <span class="n">#<%= s.rank %></span>
+        <span class="p"><%= s.priority %></span>
+      </div>
+      <div>
+        <div class="claim-name">
           <% if (s.mode_url){ %><a href="<%= s.mode_url %>" target="_blank" rel="noopener noreferrer"><%= s.name %> ↗</a><% } else { %><%= s.name %><% } %>
-          <div style="color:var(--text-dim);font-size:11px"><%= s.category||'' %> · seen <%= (s.first_seen_at||'').toString().slice(0,10) %></div>
-        </td>
-        <td><strong><%= money %></strong></td>
-        <td><%= s.deadline||'—' %></td>
-        <td><span class="chip" style="font-size:11px"><%= proof %></span></td>
-        <td style="max-width:320px;color:var(--text-dim)"><%= s.eligibility_question||'' %></td>
-        <td>
-          <select class="elig" data-id="<%= s.id %>" style="background:transparent;color:inherit;border:1px solid rgba(255,255,255,.15);border-radius:6px;padding:3px">
+        </div>
+        <div class="claim-meta"><%= s.category||'' %> · seen <%= (s.first_seen_at||'').toString().slice(0,10) %></div>
+        <div style="margin:8px 0">
+          <span class="tier-chip" style="color:<%= t.color %>"><%= t.dot %> <%= t.label %></span>
+          <span class="pill"><strong><%= money(s.payout_max_cents) %></strong></span>
+          <span class="pill <%= s.days===0?'today':(s.days!=null&&s.days<0?'past':'due') %>"><%= deadlinePill(s) %></span>
+          <span class="pill"><%= proofLabel(s) %></span>
+        </div>
+        <% if (s.eligibility_question){ %><div class="claim-meta" style="max-width:520px"><%= s.eligibility_question %></div><% } %>
+      </div>
+      <div class="ctrls">
+        <div class="ratings">
+          <%- bar('value', s.ratings.value) %>
+          <%- bar('urgency', s.ratings.urgency) %>
+          <%- bar('ease', s.ratings.ease) %>
+          <%- bar('win likelihood', s.ratings.winlikelihood) %>
+        </div>
+        <div style="display:flex;gap:8px;align-items:center">
+          <% var es = s.eligibility_state||'unreviewed'; %>
+          <select class="elig" data-id="<%= s.id %>" style="background:transparent;color:inherit;border:1px solid rgba(255,255,255,.15);border-radius:6px;padding:3px;flex:1">
             <% ['unreviewed','eligible','maybe','not_eligible'].forEach(function(o){ %>
               <option value="<%= o %>" <%= es===o?'selected':'' %>><%= o %></option>
             <% }) %>
           </select>
-        </td>
-        <td>
-          <% if (s.fill_state==='queued'){ %><span class="chip">queued</span>
-          <% } else if (s.fill_state==='prefilled_awaiting_submit'){ %><span class="chip" style="color:#e0a030">review &amp; submit</span>
+          <% if (s.fill_state==='queued'){ %><span class="pill">queued</span>
+          <% } else if (s.fill_state==='prefilled_awaiting_submit'){ %><span class="pill due">review &amp; submit</span>
           <% } else { %><button class="button fill" data-id="<%= s.id %>" <%= es==='eligible'?'':'disabled' %>>Prefill via openclaw</button><% } %>
-        </td>
-      </tr>
-    <% }) %>
-    </tbody>
-  </table>
+        </div>
+      </div>
+    </div>
+  <% }) %>
 </section>
+
+<% if (expired.length){ %>
+<section style="margin-top:26px">
+  <h2 style="font-size:15px;color:var(--text-dim)">Recently expired <span class="pill"><%= expired.length %></span></h2>
+  <% expired.forEach(function(s){ var t = TIER[s.tier] || TIER.low; %>
+    <div class="claim-card expired" data-id="<%= s.id %>">
+      <div class="rank-badge"><span class="n">—</span><span class="p"><%= s.priority %></span></div>
+      <div>
+        <div class="claim-name">
+          <% if (s.mode_url){ %><a href="<%= s.mode_url %>" target="_blank" rel="noopener noreferrer"><%= s.name %> ↗</a><% } else { %><%= s.name %><% } %>
+        </div>
+        <div class="claim-meta"><%= s.category||'' %> · <%= money(s.payout_max_cents) %> · <%= deadlinePill(s) %></div>
+      </div>
+      <div class="ctrls"><span class="tier-chip" style="color:<%= t.color %>"><%= t.dot %> <%= t.label %></span></div>
+    </div>
+  <% }) %>
+</section>
+<% } %>
+
 <script>
 document.querySelectorAll('.elig').forEach(function(sel){
   sel.addEventListener('change', function(){

← 68dc94c claims-alert: fix daysTo — pg returns DATE as Date obj (was  ·  back to AbramsOS  ·  deploy: nginx vhost for abramsos.agentabrams.com (Kamatera r 6e958d9 →