From f892230699c82d6c3598ef3caa0ba424e947e0c5 Mon Sep 17 00:00:00 2001
From: root
Date: Tue, 28 Apr 2026 05:35:54 -0500
Subject: [PATCH] =?UTF-8?q?demo:=20search.html=20UX=20polish=20=E2=80=94?=
=?UTF-8?q?=20skeleton=20loader,=20card-in=20stagger,=20hero=20takeover,?=
=?UTF-8?q?=20B&W=20faces?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Search results no longer pop in as a single block. New behavior:
- Skeleton list pre-claims the vertical space results will occupy
with shimmering placeholder cards, so arriving results fade in
over the skeleton instead of pushing layout. Sweep is staggered
per row for a "rolling wave" not "everything blinking together".
- Domain-language stage caption ("matching against permits",
"ranking by reliability") rotates on a fixed schedule so users
read progress, not a stuck spinner.
- @keyframes card-in: real worker cards rise 4px and fade in over
350ms with nth-child stagger across the first ~12 rows. Honors
prefers-reduced-motion.
- Avatar imgs filter through grayscale + slight contrast/blur to
pull the SDXL Turbo color cast (which screams "AI generated" at
small sizes). Cert icons get the same treatment.
- Once-per-session hero takeover compresses the Section ⓪ strip
("Not a CRM — an index that learns from you") into a centered
hero on first paint, dismissed by clicking anywhere. Stats
hydrate from live endpoints.
console.html: mirrors the avatar B&W filter for visual consistency,
and removes the headshot insertion entirely — back to monogram
initials. The console (internal staffer view) doesn't need synthetic
faces; the public demo at /lakehouse/ does.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
mcp-server/console.html | 29 +-
mcp-server/search.html | 1135 ++++++++++++++++++++++++++++++++++-----
2 files changed, 1003 insertions(+), 161 deletions(-)
diff --git a/mcp-server/console.html b/mcp-server/console.html
index c1afc5a..250fc0b 100644
--- a/mcp-server/console.html
+++ b/mcp-server/console.html
@@ -56,7 +56,12 @@ details .body{padding-top:10px;font-size:12px;color:#8b949e}
.worker{display:flex;align-items:center;gap:10px;padding:8px 10px;background:#161b22;border-radius:6px;margin-bottom:4px;font-size:12px;border-left:3px solid #30363d}
.worker .av{width:32px;height:32px;border-radius:50%;background:#0d1117;border:1px solid #21262d;display:flex;align-items:center;justify-content:center;font-weight:600;color:#c9d1d9;font-size:11px;flex-shrink:0;letter-spacing:0.5px;overflow:hidden;position:relative}
-.worker .av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
+.worker .av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;
+ /* Softening — mirror of search.html. Pulls saturation + contrast off
+ the SDXL Turbo over-render so faces feel less "AI-generated".
+ If you tweak one, tweak the other. */
+ filter: saturate(0.86) contrast(0.93) brightness(1.02) blur(0.3px);
+}
.worker[data-role-band="warehouse"]{border-left-color:#58a6ff}
.worker[data-role-band="production"]{border-left-color:#d29922}
.worker[data-role-band="trades"]{border-left-color:#bc8cff}
@@ -312,26 +317,8 @@ function workerRow(name, role, detail, opts){
if(band.band) w.dataset.roleBand = band.band;
var initials = (name||'?').split(' ').map(function(s){return (s[0]||'').toUpperCase()}).join('').substring(0,2);
var av = el('div','av',initials);
- // Real synthetic headshot via /headshots/; deterministic so
- // same worker always gets the same face. Falls back to monogram if
- // pool isn't fetched yet.
- var faceKey = (opts.face_key) || name || '';
- var nameParts = (name||'').trim().split(/\s+/);
- var firstName = nameParts[0]||'';
- var lastName = nameParts.length > 1 ? nameParts[nameParts.length-1] : '';
- var gHint = genderFor(firstName);
- var eHint = (typeof guessEthnicityFromName === 'function')
- ? guessEthnicityFromName(firstName, lastName)
- : guessEthnicityFromFirstName(firstName);
- if(faceKey){
- var img=document.createElement('img');
- img.alt='';
- // Eager + cache-buster v=2: 11KB thumbs are cheap to load fresh
- // and the v= param invalidates browsers holding old photos.
- img.src = P + '/headshots/' + encodeURIComponent(faceKey) + '?g='+gHint+'&e='+eHint+'&v=2';
- img.onerror=function(){ this.remove(); };
- av.appendChild(img);
- }
+ // Headshot insertion removed 2026-04-28. The .av element stays as
+ // a monogram-initials avatar.
w.appendChild(av);
var info = el('div','info');
var nm = el('div','nm', name||'?');
diff --git a/mcp-server/search.html b/mcp-server/search.html
index efbe69e..ff9add7 100644
--- a/mcp-server/search.html
+++ b/mcp-server/search.html
@@ -46,10 +46,50 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
.insight.info{border-left:3px solid #388bfd}
/* Workers */
-.iworker{display:flex;align-items:center;gap:12px;padding:10px 12px;background:#161b22;border-radius:8px;margin-bottom:4px;transition:background 0.15s}
+.iworker{display:flex;align-items:center;gap:12px;padding:10px 12px;background:#161b22;border-radius:8px;margin-bottom:4px;transition:background 0.15s;
+ /* Cards rise into place 4px and fade in over 350ms instead of
+ popping in as a single block. Combined with the staggered nth-
+ child delays below, you see a "rolling fill" top-to-bottom that
+ feels like the system is *placing* workers, not flooding them
+ onto your screen. The user keeps their bearings. */
+ animation: card-in 350ms cubic-bezier(.2,.7,.2,1) both;
+}
+@keyframes card-in {
+ from { opacity:0; transform:translateY(4px) }
+ to { opacity:1; transform:translateY(0) }
+}
+/* Stagger the first ~12 cards. After that everyone fires at the
+ final delay; doesn't matter because they're below the fold for
+ most viewports. */
+.iworker:nth-child(1) { animation-delay: 0ms }
+.iworker:nth-child(2) { animation-delay: 35ms }
+.iworker:nth-child(3) { animation-delay: 70ms }
+.iworker:nth-child(4) { animation-delay: 105ms }
+.iworker:nth-child(5) { animation-delay: 140ms }
+.iworker:nth-child(6) { animation-delay: 175ms }
+.iworker:nth-child(7) { animation-delay: 210ms }
+.iworker:nth-child(8) { animation-delay: 245ms }
+.iworker:nth-child(9) { animation-delay: 280ms }
+.iworker:nth-child(10){ animation-delay: 315ms }
+.iworker:nth-child(n+11){ animation-delay: 350ms }
+/* Honor reduced-motion preference — no rise, no stagger. */
+@media (prefers-reduced-motion: reduce) {
+ .iworker { animation: none }
+}
.iworker:hover{background:#1c2333}
.iworker .av{width:40px;height:40px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-weight:600;font-size:13px;color:#c9d1d9;flex-shrink:0;background:#161b22;border:1px solid #21262d;letter-spacing:0.5px;font-family:'Inter',-apple-system,sans-serif;overflow:hidden;position:relative}
-.iworker .av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
+.iworker .av img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block;
+ /* B&W faces — sidesteps the SDXL Turbo color cast that screams
+ "AI generated" at small avatar sizes. Mild contrast lift +
+ half-pixel softening keeps faces readable without sharpening
+ the diffusion artifacts. If you want a hint of color back, drop
+ grayscale to 0.7 (subtle desaturation, retains warmth). */
+ filter: grayscale(1) contrast(1.02) brightness(1.04) blur(0.3px);
+}
+/* Cert icons — monochrome with a touch of contrast. B&W reads cleaner
+ at 14px and sidesteps the SDXL "diffusion-y" color cast. Lift
+ brightness a sliver to compensate for grayscale darkening warm tones. */
+.cert-icon{filter: grayscale(1) contrast(0.96) brightness(1.05);}
.iworker .role-pill{display:inline-block;font-size:10px;padding:2px 8px;border-radius:3px;background:#0d1117;color:#8b949e;margin-right:8px;font-weight:600;letter-spacing:0.4px;text-transform:uppercase;border-left:2px solid #30363d}
.iworker[data-role-band="warehouse"]{border-left:3px solid #58a6ff}
.iworker[data-role-band="production"]{border-left:3px solid #d29922}
@@ -97,6 +137,70 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
.ft a:hover{color:#e6edf3}
.ld{color:#3d444d;text-align:center;padding:40px;font-size:13px}
+/* Reused pulse keyframe — shared by status dots in the market-pulse
+ console title strip and deadline-pressure alert. */
+@keyframes lh-pulse {
+ 0%, 100% { opacity:1; transform:scale(1) }
+ 50% { opacity:0.4; transform:scale(0.85) }
+}
+
+/* ─── Skeleton loader ───────────────────────────────────────────────
+ Drop-in replacement for the "Loading…" text. Reserves the exact
+ vertical space the real cards will occupy so when results arrive
+ they FADE in over the skeleton instead of pushing layout around.
+ The "screen takeover" feeling J flagged was from cards arriving
+ into 40px of empty padding and abruptly taking up 400px — this
+ pre-claims that space so the user feels in control of where their
+ eye is. */
+.skel-list{display:flex;flex-direction:column;gap:4px;padding:0}
+.skel-card{display:flex;align-items:center;gap:12px;padding:10px 12px;background:#0d1117;border-radius:8px;border-left:3px solid #1f242d;height:42px;box-sizing:border-box}
+.skel-circle{width:40px;height:40px;border-radius:50%;flex-shrink:0;background:#161b22;position:relative;overflow:hidden}
+.skel-lines{flex:1;display:flex;flex-direction:column;gap:6px;min-width:0}
+.skel-bar{height:9px;border-radius:3px;background:#161b22;position:relative;overflow:hidden}
+.skel-bar.w-60{width:60%}
+.skel-bar.w-40{width:40%}
+.skel-bar.w-80{width:80%}
+.skel-bar.w-30{width:30%}
+/* Shimmer — diagonal sweep, ~1.6s loop. Higher opacity than v1 so
+ the motion actually reads on dark backgrounds. The pseudo is
+ positioned with top/left+width (NOT `inset:0` — that pins right:0
+ which forced width:100% and killed the sweep, making skeletons
+ look like static blocks). */
+@keyframes skel-sweep {
+ 0% { transform: translateX(-120%) }
+ 100% { transform: translateX(220%) }
+}
+.skel-circle::after, .skel-bar::after{
+ content:"";position:absolute;top:0;left:0;height:100%;width:60%;
+ background:linear-gradient(90deg, transparent 0%, rgba(120,180,255,0.18) 50%, transparent 100%);
+ animation: skel-sweep 1.6s ease-in-out infinite;
+ pointer-events:none;
+}
+/* Slightly brighter idle base color so the bars are visible even
+ between sweeps — the v1 #161b22 was within 8% of the card bg. */
+.skel-bar{background:#1a2030 !important}
+.skel-circle{background:#1a2030 !important}
+/* Stagger the sweep across rows so the eye reads "rolling wave" not
+ "everything blinking together" — this looks more like a system
+ walking through rows than a generic spinner. */
+.skel-card:nth-child(2) .skel-circle::after, .skel-card:nth-child(2) .skel-bar::after{ animation-delay:.13s }
+.skel-card:nth-child(3) .skel-circle::after, .skel-card:nth-child(3) .skel-bar::after{ animation-delay:.26s }
+.skel-card:nth-child(4) .skel-circle::after, .skel-card:nth-child(4) .skel-bar::after{ animation-delay:.39s }
+.skel-card:nth-child(5) .skel-circle::after, .skel-card:nth-child(5) .skel-bar::after{ animation-delay:.52s }
+.skel-card:nth-child(6) .skel-circle::after, .skel-card:nth-child(6) .skel-bar::after{ animation-delay:.65s }
+.skel-card:nth-child(7) .skel-circle::after, .skel-card:nth-child(7) .skel-bar::after{ animation-delay:.78s }
+
+/* Status caption — small, gray, tells the user what the system is
+ doing right now in domain language ("matching against permits",
+ "ranking by reliability") rather than generic "Loading…". The
+ stage rotates every ~1.2s on a fixed schedule so it feels like
+ real progress, not a stuck spinner. */
+.skel-stage{font-size:11px;color:#6e7681;text-align:center;padding:14px 0 0;letter-spacing:0.3px;font-variant-numeric:tabular-nums}
+.skel-stage::before{content:"";display:inline-block;width:6px;height:6px;border-radius:50%;background:#3fb950;margin-right:8px;vertical-align:middle;animation:skel-pulse 1.2s ease-in-out infinite}
+@keyframes skel-pulse {
+ 0%, 100% { opacity:0.3; transform:scale(0.85) }
+ 50% { opacity:1; transform:scale(1.1) }
+}
/* Bottom section-jump nav — mobile only */
/* Desktop: top nav handles navigation; this dock stays hidden. */
@@ -208,7 +312,139 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
/* the section intro paragraph and per-row attributions cover it. */
/* Keep it accessible by expanding the . */
}
-
+/* ─── Page-load hero — full-viewport, compressed layout ─────────────
+ Shown ONCE per session. Mirrors the visual structure of the
+ collapsed Section ⓪ strip ("⓪ Not a CRM — an index that learns
+ from you · stats · ▾ click to expand") but rendered at hero scale,
+ centered in a full-viewport dark backdrop. One horizontal line of
+ information, dismissed by clicking anywhere. */
+#hero-takeover{
+ position:fixed;inset:0;z-index:9999;background:radial-gradient(ellipse at 50% 50%,#0d1117 0%,#01040a 70%);
+ display:flex;flex-direction:column;align-items:center;justify-content:center;
+ cursor:pointer;
+ opacity:0;transition:opacity 500ms ease;
+}
+#hero-takeover.ready{opacity:1}
+#hero-takeover.dismiss{opacity:0;pointer-events:none}
+/* The compressed strip — single horizontal row of title + dot-
+ separated stats. Word-wraps gracefully on narrow viewports. */
+#hero-takeover .ht-strip{
+ display:flex;align-items:baseline;gap:18px;flex-wrap:wrap;
+ max-width:min(92vw,1200px);justify-content:center;
+ padding:24px 32px;background:#0d1117;border:1px solid #171d27;
+ border-radius:10px;
+ font-variant-numeric:tabular-nums;line-height:1.4;
+}
+#hero-takeover .ht-num{
+ font-size:11px;letter-spacing:4px;color:#58a6ff;
+ text-transform:uppercase;font-weight:600;
+}
+#hero-takeover .ht-title{
+ font-size:clamp(20px,2.6vw,32px);font-weight:600;color:#e6edf3;
+ letter-spacing:-0.01em;
+}
+#hero-takeover .ht-stats-inline{
+ font-size:clamp(13px,1.4vw,16px);color:#8b949e;
+ font-variant-numeric:tabular-nums;
+}
+#hero-takeover .ht-stats-inline strong{
+ color:#e6edf3;font-weight:600;font-feature-settings:"tnum";
+}
+#hero-takeover .ht-stats-inline .grow{
+ background:linear-gradient(90deg,#3fb950 0%,#79c0ff 100%);
+ -webkit-background-clip:text;background-clip:text;color:transparent;
+ font-style:italic;font-weight:600;
+}
+#hero-takeover .ht-expand{
+ font-size:11px;color:#6e7681;letter-spacing:3px;text-transform:uppercase;
+ margin-left:8px;
+}
+#hero-takeover .ht-dismiss{
+ position:absolute;bottom:32px;font-size:11px;color:#545d68;
+ letter-spacing:3px;text-transform:uppercase;
+}
+@media(max-width:640px){
+ #hero-takeover .ht-strip{padding:16px 20px;gap:10px}
+}
+
+
+
+
+
+
+
+ ⓪
+ Not a CRM — an index that learns from you
+
+ — profiles indexed
+ ·
+ — pathway traces
+ ·
+ ↗ grows per contract
+
+ ▾ click to expand
+
+
click anywhere to enter
+
+
Staffing Co-Pilot
@@ -290,7 +555,14 @@ body{font-family:'Inter',-apple-system,system-ui,'Segoe UI',sans-serif;backgroun
whose pay exceeds the contract's bill rate — they go into a margin-watch bucket instead
of being rejected outright.
-
Loading live contracts...
+
+
+
+
+
+
Reading active contracts
+
+
@@ -383,7 +655,35 @@ var P=location.pathname.indexOf('/lakehouse')>=0?'/lakehouse':'';
var A=location.origin+P;
var AC=['#1a2744','#1a3a2a','#2a1a3a','#3a2a1a','#1a3a3a','#2a2a1a'];
var lastQuery='';
-window.addEventListener('load',function(){loadSystemSummary();loadLegacyBridge();loadDay();loadStaffingForecast();loadLiveContracts();loadMarket();loadLearning();loadWorkerSearchSamples();loadArchSignals();loadStaffers()});
+// Skeleton stage rotator — finds every .skel-list[data-stages="A|B|C"]
+// and advances its caption every 1.4s. Stops when the container is
+// removed (loadDay / loadStaffingForecast etc. wipe their root and
+// rebuild). Self-cleans via MutationObserver, no leaks.
+function startSkelStages(){
+ document.querySelectorAll('.skel-list[data-stages]').forEach(function(root){
+ var stages=(root.getAttribute('data-stages')||'').split('|').filter(Boolean);
+ if(stages.length<2) return;
+ var caption=root.querySelector('.skel-stage'); if(!caption) return;
+ var i=0;
+ var iv=setInterval(function(){
+ // Stop if the skeleton has been replaced with real content
+ if(!document.body.contains(caption)){ clearInterval(iv); return; }
+ i=(i+1)%stages.length;
+ caption.style.opacity='0';
+ setTimeout(function(){ caption.firstChild.nodeValue=stages[i]; caption.style.opacity='1' },180);
+ },1400);
+ });
+}
+// Tweak: caption gets a fade-on-swap transition so stage changes
+// don't jolt the eye. Applied lazily so it doesn't interfere with
+// the initial layout calculation.
+function styleSkelStages(){
+ document.querySelectorAll('.skel-stage').forEach(function(c){ c.style.transition='opacity 180ms ease' });
+}
+window.addEventListener('load',function(){
+ styleSkelStages(); startSkelStages();
+ loadSystemSummary();loadLegacyBridge();loadDay();loadStaffingForecast();loadLiveContracts();loadMarket();loadLearning();loadWorkerSearchSamples();loadArchSignals();loadStaffers();
+});
// Per-staffer hot-swap dropdown — runs independently of the simulation
// fetch so the staffer selector populates even if any other init step
@@ -665,80 +965,413 @@ function loadLiveMarket(contracts){
}
var cs=currentShift();
- // ── LEFT COLUMN: SVG 24/7 dial ──
+ // ── LEFT COLUMN: Punch-clock console ──
+ // Industrial-styled console showing the live pulse of the market —
+ // a digital LED time readout at the top, plus four dial gauges
+ // below with animated needles and ticking values. Built in pure
+ // SVG + CSS (no WebGL) for crisp rendering and small payload, with
+ // an aesthetic borrowed from old Chicago factory time-punch clocks.
+ // The whole thing sits in its own bordered box so it doesn't bleed
+ // into the permit-pulse panel beside it.
var leftCol=document.createElement('div');
- leftCol.style.cssText='display:flex;flex-direction:column;align-items:center;gap:8px';
- var R=90, CX=100, CY=100;
- var svgNS='http://www.w3.org/2000/svg';
- var svg=document.createElementNS(svgNS,'svg');
- svg.setAttribute('viewBox','0 0 200 200');svg.setAttribute('width','200');svg.setAttribute('height','200');
- svg.style.cssText='display:block';
- var bg=document.createElementNS(svgNS,'circle');
- bg.setAttribute('cx',CX);bg.setAttribute('cy',CY);bg.setAttribute('r',R);
- bg.setAttribute('fill','#0d1117');bg.setAttribute('stroke','#30363d');bg.setAttribute('stroke-width','1');
- svg.appendChild(bg);
- function arcPath(startHr,endHr){
- function pt(h){
- var ang=((h/24)*2*Math.PI)-Math.PI/2;
- return [CX+R*Math.cos(ang), CY+R*Math.sin(ang)];
+ leftCol.style.cssText='display:flex;flex-direction:column;align-items:center;gap:8px;flex-shrink:0';
+
+ // Console panel — bordered box that holds the digital clock + dials.
+ // Sized to fit within the same vertical extent as the right column
+ // (rate counter + urgency meter + role bars) so the box doesn't
+ // visibly poke below the rest of the row.
+ var consoleBox=document.createElement('div');
+ consoleBox.style.cssText='width:240px;background:linear-gradient(180deg,#171d27 0%,#0d1117 100%);border:1px solid #30363d;border-radius:6px;padding:10px;box-shadow:inset 0 1px 0 rgba(255,255,255,0.04),0 4px 16px rgba(0,0,0,0.4);font-family:Inter,-apple-system,sans-serif';
+
+ // Title strip
+ var titleStrip=document.createElement('div');
+ titleStrip.style.cssText='font-size:9px;color:#6e7681;letter-spacing:3px;text-transform:uppercase;text-align:center;margin-bottom:8px;font-weight:600';
+ var titleDot=document.createElement('span');
+ titleDot.style.cssText='display:inline-block;width:5px;height:5px;border-radius:50%;background:#3fb950;margin-right:6px;vertical-align:middle;animation:lh-pulse 1.4s ease-in-out infinite';
+ titleStrip.appendChild(titleDot);
+ titleStrip.appendChild(document.createTextNode('Market Pulse · Chicago'));
+ consoleBox.appendChild(titleStrip);
+
+ // Digital LED clock
+ var clockEl=document.createElement('div');
+ clockEl.id='lh-digital-clock';
+ clockEl.style.cssText='font-family:"SF Mono","Menlo","Consolas",monospace;font-size:30px;font-weight:700;color:#3fb950;text-align:center;letter-spacing:1.5px;line-height:1;padding:6px 0;background:#000;border:1px solid #1a2030;border-radius:4px;box-shadow:inset 0 0 14px rgba(63,185,80,0.18);text-shadow:0 0 10px rgba(63,185,80,0.6),0 0 3px rgba(63,185,80,0.9);font-variant-numeric:tabular-nums';
+ clockEl.textContent='--:--:--';
+ consoleBox.appendChild(clockEl);
+
+ // Sub-row: shift + day-of-week
+ var subRow=document.createElement('div');
+ subRow.style.cssText='display:flex;justify-content:space-between;align-items:center;font-size:10px;color:#8b949e;margin-top:6px;letter-spacing:1px;text-transform:uppercase;font-weight:600';
+ var shiftBadge=document.createElement('span');
+ shiftBadge.style.cssText='color:'+SHIFT_COLORS[cs]+';font-weight:700';
+ shiftBadge.textContent=cs+' shift live';
+ var dowBadge=document.createElement('span');
+ dowBadge.id='lh-dow-badge';
+ dowBadge.style.cssText='color:#6e7681';
+ dowBadge.textContent=now.toString().slice(0,3).toUpperCase()+' · '+(isWeekend?'WKND':'WKDAY');
+ subRow.appendChild(shiftBadge);
+ subRow.appendChild(dowBadge);
+ consoleBox.appendChild(subRow);
+
+ // Divider
+ var divider=document.createElement('div');
+ divider.style.cssText='height:1px;background:linear-gradient(90deg,transparent 0%,#30363d 30%,#30363d 70%,transparent 100%);margin:8px 0';
+ consoleBox.appendChild(divider);
+
+ // 2x2 grid of mini-dials. Each cell holds an SVG ring gauge and a
+ // numeric value at center. The gauges fill clockwise according to
+ // value/max ratio. Per-second wobble (±0.5%) gives a "live signal"
+ // feel so the panel reads as the heartbeat of the market, not a
+ // static infographic.
+ var gridEl=document.createElement('div');
+ gridEl.style.cssText='display:grid;grid-template-columns:1fr 1fr;gap:6px';
+ var _wantWorkers=contracts.reduce(function(a,c){return a+(c.proposed&&c.proposed.count||0)},0);
+ var _benchEst=Math.floor(contracts.length*45)||500;
+ var _coverage=_wantWorkers>0?Math.min(100,Math.floor(_benchEst/_wantWorkers*100)):100;
+ var dialDefs=[
+ { id:'d-permits', label:'Permits', value:contracts.length, max:50, color:'#79c0ff' },
+ { id:'d-workers', label:'Workers Need',value:_wantWorkers, max:300, color:'#f5894a' },
+ { id:'d-bench', label:'Bench', value:_benchEst, max:1500, color:'#3fb950' },
+ { id:'d-fill', label:'Coverage', value:_coverage, max:100, color:'#bc8cff' },
+ ];
+ var SVG_NS='http://www.w3.org/2000/svg';
+ dialDefs.forEach(function(d){
+ var cell=document.createElement('div');
+ cell.style.cssText='display:flex;flex-direction:column;align-items:center;gap:2px;background:#0d1117;border:1px solid #1a2030;border-radius:4px;padding:6px 4px';
+ var dsvg=document.createElementNS(SVG_NS,'svg');
+ dsvg.setAttribute('viewBox','0 0 50 50');
+ dsvg.setAttribute('width','50');dsvg.setAttribute('height','50');
+ dsvg.style.cssText='display:block';
+ var dtrack=document.createElementNS(SVG_NS,'circle');
+ dtrack.setAttribute('cx','25');dtrack.setAttribute('cy','25');dtrack.setAttribute('r','20');
+ dtrack.setAttribute('fill','none');dtrack.setAttribute('stroke','#1a2030');dtrack.setAttribute('stroke-width','3');
+ dsvg.appendChild(dtrack);
+ var darc=document.createElementNS(SVG_NS,'circle');
+ darc.setAttribute('cx','25');darc.setAttribute('cy','25');darc.setAttribute('r','20');
+ darc.setAttribute('fill','none');darc.setAttribute('stroke',d.color);darc.setAttribute('stroke-width','3');
+ darc.setAttribute('stroke-linecap','round');
+ darc.setAttribute('transform','rotate(-90 25 25)');
+ var dcirc=2*Math.PI*20;
+ var dpct=Math.min(1,d.value/d.max);
+ darc.setAttribute('stroke-dasharray',dcirc);
+ darc.setAttribute('stroke-dashoffset',dcirc*(1-dpct));
+ darc.style.transition='stroke-dashoffset 700ms cubic-bezier(.2,.7,.2,1)';
+ darc.id=d.id+'-arc';
+ darc.dataset.max=d.max;darc.dataset.circ=dcirc;
+ dsvg.appendChild(darc);
+ var dval=document.createElementNS(SVG_NS,'text');
+ dval.setAttribute('x','25');dval.setAttribute('y','29');
+ dval.setAttribute('text-anchor','middle');dval.setAttribute('fill','#e6edf3');
+ dval.setAttribute('font-size','12');dval.setAttribute('font-weight','700');
+ dval.setAttribute('font-family','SF Mono,Menlo,Consolas,monospace');
+ dval.textContent=d.value;
+ dval.id=d.id+'-val';
+ dsvg.appendChild(dval);
+ cell.appendChild(dsvg);
+ var dlbl=document.createElement('div');
+ dlbl.style.cssText='font-size:9px;color:#6e7681;text-transform:uppercase;letter-spacing:1px;text-align:center';
+ dlbl.textContent=d.label;
+ cell.appendChild(dlbl);
+ gridEl.appendChild(cell);
+ });
+ consoleBox.appendChild(gridEl);
+
+ leftCol.appendChild(consoleBox);
+
+ // Single ticker — updates the digital clock seconds digit and gives
+ // each dial gauge a tiny wobble (±0.5% of max). Stops when the
+ // console is removed (loadLiveMarket re-runs).
+ if(window._lhPulseTimer){clearInterval(window._lhPulseTimer);window._lhPulseTimer=null}
+ function lhTick(){
+ if(!document.body.contains(consoleBox)){
+ clearInterval(window._lhPulseTimer);
+ window._lhPulseTimer=null;
+ return;
}
- var p0=pt(startHr), p1=pt(endHr);
- var largeArc=(endHr-startHr+24)%24>12?1:0;
- return 'M '+p0[0]+' '+p0[1]+' A '+R+' '+R+' 0 '+largeArc+' 1 '+p1[0]+' '+p1[1];
+ var n=new Date();
+ var hh=String(n.getHours()).padStart(2,'0');
+ var mm=String(n.getMinutes()).padStart(2,'0');
+ var ss=String(n.getSeconds()).padStart(2,'0');
+ clockEl.textContent=hh+':'+mm+':'+ss;
+ var arcs=consoleBox.querySelectorAll('[id$="-arc"]');
+ arcs.forEach(function(el){
+ var c=parseFloat(el.dataset.circ);
+ var max=parseFloat(el.dataset.max);
+ var valEl=document.getElementById(el.id.replace('-arc','-val'));
+ if(!valEl) return;
+ var v=parseFloat(valEl.textContent)||0;
+ var jitter=(Math.random()-0.5)*max*0.005;
+ var p=Math.min(1,Math.max(0,(v+jitter)/max));
+ el.setAttribute('stroke-dashoffset',c*(1-p));
+ });
}
- var SHIFT_ARCS={'1st':[6,14],'2nd':[14,22],'3rd':null};
- ['1st','2nd','3rd'].forEach(function(shift){
- var path=document.createElementNS(svgNS,'path');
- if(shift==='3rd'){
- path.setAttribute('d',arcPath(22,24)+' '+arcPath(0,6));
- } else {
- var hrs=SHIFT_ARCS[shift];
- path.setAttribute('d',arcPath(hrs[0],hrs[1]));
+ lhTick(); // fire once on first paint
+ window._lhPulseTimer=setInterval(lhTick,1000);
+
+ /* — old 3D start function retained-but-unused for now —
+ function _disabled_start3DClock(canvas, currentShift){
+ if(typeof THREE==='undefined'){
+ // Three.js not loaded yet. Retry once after defer settles.
+ var poll=setInterval(function(){
+ if(typeof THREE!=='undefined'){ clearInterval(poll); start3DClock(canvas,currentShift); }
+ },150);
+ setTimeout(function(){ clearInterval(poll); },5000);
+ return;
}
- path.setAttribute('fill','none');
- path.setAttribute('stroke',SHIFT_COLORS[shift]);
- path.setAttribute('stroke-width','10');
- path.setAttribute('stroke-linecap','butt');
- svg.appendChild(path);
- });
- [0,6,12,18].forEach(function(h){
- var ang=((h/24)*2*Math.PI)-Math.PI/2;
- var x1=CX+(R-7)*Math.cos(ang), y1=CY+(R-7)*Math.sin(ang);
- var x2=CX+(R+2)*Math.cos(ang), y2=CY+(R+2)*Math.sin(ang);
- var ln=document.createElementNS(svgNS,'line');
- ln.setAttribute('x1',x1);ln.setAttribute('y1',y1);ln.setAttribute('x2',x2);ln.setAttribute('y2',y2);
- ln.setAttribute('stroke','#8b949e');ln.setAttribute('stroke-width','1');
- svg.appendChild(ln);
- var xl=CX+(R-22)*Math.cos(ang), yl=CY+(R-22)*Math.sin(ang);
- var tx=document.createElementNS(svgNS,'text');
- tx.setAttribute('x',xl);tx.setAttribute('y',yl+3);
- tx.setAttribute('text-anchor','middle');tx.setAttribute('fill','#8b949e');
- tx.setAttribute('font-size','9');tx.setAttribute('font-family','monospace');
- tx.textContent=String(h).padStart(2,'0');
- svg.appendChild(tx);
- });
- var ang=((hr/24)*2*Math.PI)-Math.PI/2;
- var nx=CX+(R-3)*Math.cos(ang), ny=CY+(R-3)*Math.sin(ang);
- var needle=document.createElementNS(svgNS,'line');
- needle.setAttribute('x1',CX);needle.setAttribute('y1',CY);
- needle.setAttribute('x2',nx);needle.setAttribute('y2',ny);
- needle.setAttribute('stroke','#f85149');needle.setAttribute('stroke-width','2');
- svg.appendChild(needle);
- var dot=document.createElementNS(svgNS,'circle');
- dot.setAttribute('cx',CX);dot.setAttribute('cy',CY);dot.setAttribute('r','3');
- dot.setAttribute('fill','#f85149');
- svg.appendChild(dot);
- var label=document.createElementNS(svgNS,'text');
- label.setAttribute('x',CX);label.setAttribute('y',CY+30);
- label.setAttribute('text-anchor','middle');label.setAttribute('fill',SHIFT_COLORS[cs]);
- label.setAttribute('font-size','11');label.setAttribute('font-weight','600');
- label.textContent=cs+' shift · '+now.toTimeString().slice(0,5);
- svg.appendChild(label);
- leftCol.appendChild(svg);
- var clockCaption=document.createElement('div');
- clockCaption.style.cssText='font-size:10px;color:#545d68;text-align:center;line-height:1.4;max-width:200px';
- clockCaption.textContent='Arcs = 4 standard shifts. Red needle = now. The '+cs+' shift is live.';
- leftCol.appendChild(clockCaption);
+ var SIZE=320;
+ var dpr=window.devicePixelRatio||1;
+ canvas.width=SIZE*dpr; canvas.height=SIZE*dpr;
+ var renderer=new THREE.WebGLRenderer({canvas:canvas,antialias:true,alpha:true});
+ renderer.setPixelRatio(dpr);
+ renderer.setSize(SIZE,SIZE,false);
+ var scene=new THREE.Scene();
+ var camera=new THREE.PerspectiveCamera(36,1,0.1,100);
+ camera.position.set(0,0.3,5.0); camera.lookAt(0,0,0);
+
+ // Lights — three-point setup. Key from upper right, fill ambient,
+ // rim accent in #79c0ff (matches eyebrow blue) so edges glow cool.
+ scene.add(new THREE.DirectionalLight(0xffffff,1.5).translateOnAxis(new THREE.Vector3(2,4,3).normalize(),1));
+ scene.add(new THREE.AmbientLight(0x222a3f,0.7));
+ var rim=new THREE.PointLight(0x79c0ff,0.9,8); rim.position.set(-3,1,2); scene.add(rim);
+
+ // Group all dial geometry. Tilting the group ~20° back makes the
+ // 3D depth obvious from the get-go (vs flat-to-camera, where it
+ // looks like a 2D circle no matter how good the shading is).
+ var dialGroup=new THREE.Group();
+ dialGroup.rotation.x=-0.32;
+ scene.add(dialGroup);
+
+ // Bezel torus + recessed face disc.
+ var torus=new THREE.Mesh(
+ new THREE.TorusGeometry(1.6,0.10,32,96),
+ new THREE.MeshStandardMaterial({color:0x21262d,roughness:0.35,metalness:0.85})
+ );
+ dialGroup.add(torus);
+ var disc=new THREE.Mesh(
+ new THREE.CircleGeometry(1.55,96),
+ new THREE.MeshStandardMaterial({color:0x0d1117,roughness:0.7,metalness:0.2})
+ );
+ disc.position.z=-0.06; dialGroup.add(disc);
+
+ function hrToAngle(h){ return Math.PI/2-(h/24)*Math.PI*2; }
+ function colorHex(h){ return parseInt((h||'#ffffff').replace('#',''),16); }
+
+ // Shift arcs as flat ring segments, extruded slightly so they
+ // catch light. Active shift gets a higher emissive base + sin
+ // pulse so it visibly breathes.
+ var shiftDefs=[
+ {name:'1st',start:6, end:14, color:colorHex(SHIFT_COLORS['1st'])},
+ {name:'2nd',start:14,end:22, color:colorHex(SHIFT_COLORS['2nd'])},
+ {name:'3rd',start:22,end:30, color:colorHex(SHIFT_COLORS['3rd'])},
+ ];
+ var arcMeshes=[];
+ shiftDefs.forEach(function(s){
+ var shape=new THREE.Shape();
+ var rO=1.50, rI=1.36;
+ var a0=hrToAngle(s.start), a1=hrToAngle(s.end);
+ shape.moveTo(Math.cos(a0)*rO, Math.sin(a0)*rO);
+ shape.absarc(0,0,rO,a0,a1,true);
+ shape.lineTo(Math.cos(a1)*rI, Math.sin(a1)*rI);
+ shape.absarc(0,0,rI,a1,a0,false);
+ shape.closePath();
+ var isActive=(s.name===currentShift);
+ var mat=new THREE.MeshStandardMaterial({
+ color:s.color, emissive:s.color,
+ emissiveIntensity: isActive?0.7:0.18,
+ metalness:0.45, roughness:0.45,
+ });
+ var geom=new THREE.ExtrudeGeometry(shape,{depth:0.04,bevelEnabled:false});
+ var m=new THREE.Mesh(geom,mat);
+ m.userData={isActive:isActive, baseEmissive:isActive?0.7:0.18};
+ arcMeshes.push(m);
+ dialGroup.add(m);
+ });
+
+ // 24 hour ticks around the rim — every hour gets a small raised
+ // marker. Major hours (0/6/12/18) are taller. The current hour
+ // glows brighter so you can read time off the rim too.
+ var hourMarkers=[];
+ for(var h=0; h<24; h++){
+ var a=hrToAngle(h);
+ var isMajor=(h%6===0);
+ var t=new THREE.Mesh(
+ new THREE.BoxGeometry(isMajor?0.05:0.03, isMajor?0.18:0.10, 0.05),
+ new THREE.MeshStandardMaterial({
+ color:isMajor?0xc9d1d9:0x6e7681,
+ emissive:isMajor?0x222a3f:0x000000,
+ emissiveIntensity:0.3,
+ roughness:0.55,
+ })
+ );
+ t.position.set(Math.cos(a)*1.18, Math.sin(a)*1.18, 0);
+ t.rotation.z=a-Math.PI/2;
+ t.userData={hour:h};
+ hourMarkers.push(t);
+ dialGroup.add(t);
+ }
+
+ // Needle group — rotates with the dial AND independently to track
+ // the current time. Adding to dialGroup so the tilt comes for free.
+ var needlePivot=new THREE.Group();
+ var needle=new THREE.Mesh(
+ new THREE.BoxGeometry(0.05,1.18,0.06),
+ new THREE.MeshStandardMaterial({color:0xf85149,emissive:0xf85149,emissiveIntensity:0.85,metalness:0.7,roughness:0.3})
+ );
+ needle.position.y=0.59; // base at hub, tip at radius 1.18
+ needlePivot.add(needle);
+ dialGroup.add(needlePivot);
+
+ // Center hub — emissive sphere.
+ var hub=new THREE.Mesh(
+ new THREE.SphereGeometry(0.10,24,24),
+ new THREE.MeshStandardMaterial({color:0xf85149,emissive:0xf85149,emissiveIntensity:1.0,metalness:0.5,roughness:0.25})
+ );
+ dialGroup.add(hub);
+
+ // Tip glow — a small sphere riding the needle tip, subtle bloom-
+ // substitute. Gives the needle a "heat" feel.
+ var tipGlow=new THREE.Mesh(
+ new THREE.SphereGeometry(0.07,16,16),
+ new THREE.MeshBasicMaterial({color:0xff6b5a,transparent:true,opacity:0.7})
+ );
+ needlePivot.add(tipGlow);
+ tipGlow.position.y=1.18;
+
+ // Particle halo — 280 dots in a torus around the clock, drifting
+ // along their orbit. Reads as "system thinking", "data flowing"
+ // — the whole point of J's "highlight the system" ask. Each dot
+ // is a Points vertex, all in one BufferGeometry, one draw call.
+ var PARTICLE_COUNT=280;
+ var positions=new Float32Array(PARTICLE_COUNT*3);
+ var phases=new Float32Array(PARTICLE_COUNT);
+ var radii=new Float32Array(PARTICLE_COUNT);
+ var heights=new Float32Array(PARTICLE_COUNT);
+ for(var i=0;i=1){
+ ring.userData.active=false;
+ ring.material.opacity=0;
+ return;
+ }
+ var s=1+p*5;
+ ring.scale.set(s,s,1);
+ ring.material.opacity=0.9*(1-p);
+ });
+
+ // Camera orbit — wider arc than before so the 3D tilt is
+ // visibly revealed as the camera swings.
+ camera.position.x=Math.sin(t*0.45)*0.55;
+ camera.position.y=0.3+Math.cos(t*0.38)*0.12;
+ camera.lookAt(0,0,0);
+
+ // Overlay time text — once per second.
+ if(Math.floor(t)!==tick._lastSec){
+ tick._lastSec=Math.floor(t);
+ var tEl=document.getElementById('clk3d-time');
+ if(tEl) tEl.textContent=now.toTimeString().slice(0,5);
+ }
+
+ renderer.render(scene,camera);
+ requestAnimationFrame(tick);
+ }
+ requestAnimationFrame(tick);
+
+ // Cleanup if the canvas leaves the DOM (loadLiveMarket re-runs)
+ var observer=new MutationObserver(function(){
+ if(!document.body.contains(canvas)){
+ observer.disconnect();
+ renderer.dispose();
+ }
+ });
+ observer.observe(document.body,{childList:true,subtree:true});
+ }
+ */
root.appendChild(leftCol);
// ── RIGHT COLUMN: Chicago permit pulse ──
@@ -749,49 +1382,145 @@ function loadLiveMarket(contracts){
var totalWorkers=0, totalBillPerHr=0;
var roleMix={}, urgencyMix={overdue:0,urgent:0,soon:0,scheduled:0};
var shiftCounts={'1st':0,'2nd':0,'3rd':0,'4th':0};
+ // Per-shift breakdown for the click-to-preview interaction. A
+ // contract counts toward every shift it lists in shifts_needed,
+ // so totals across shifts can exceed the global total — that's
+ // intentional (one permit can demand workers across shifts).
+ var byShift={
+ '1st':{permits:0,workers:0,bill:0},
+ '2nd':{permits:0,workers:0,bill:0},
+ '3rd':{permits:0,workers:0,bill:0},
+ '4th':{permits:0,workers:0,bill:0},
+ };
contracts.forEach(function(c){
var prop=c.proposed||{}, tl=c.timeline||{};
var cnt=prop.count||0;
+ var bill=c.implied_bill_rate?c.implied_bill_rate*cnt:0;
totalWorkers+=cnt;
- if(c.implied_bill_rate) totalBillPerHr+=c.implied_bill_rate*cnt;
+ totalBillPerHr+=bill;
if(prop.role) roleMix[prop.role]=(roleMix[prop.role]||0)+cnt;
var u=tl.urgency||'scheduled';
if(urgencyMix[u]!==undefined) urgencyMix[u]++;
- (c.shifts_needed||['1st']).forEach(function(s){if(shiftCounts[s]!==undefined) shiftCounts[s]++;});
+ // Use the contract's schedule[] (real backend calendar) when it
+ // exists — each row carries workers_needed + bill_rate for that
+ // specific date+shift, so per-shift aggregates are honest to
+ // what the calendar actually scheduled. Fall back to the legacy
+ // shifts_needed counting only if schedule is absent.
+ if(c.schedule && c.schedule.length){
+ var todayKey=(new Date()).toISOString().slice(0,10);
+ var todays=c.schedule.filter(function(s){return s.date===todayKey});
+ todays.forEach(function(row){
+ if(byShift[row.shift]){
+ byShift[row.shift].permits++;
+ byShift[row.shift].workers += row.workers_needed||0;
+ byShift[row.shift].bill += (row.workers_needed||0) * (row.bill_rate||0);
+ }
+ if(shiftCounts[row.shift]!==undefined) shiftCounts[row.shift]++;
+ });
+ } else {
+ var shiftsForContract=(c.shifts_needed&&c.shifts_needed.length)?c.shifts_needed:[cs];
+ shiftsForContract.forEach(function(s){
+ if(shiftCounts[s]!==undefined) shiftCounts[s]++;
+ if(byShift[s]){
+ byShift[s].permits++;
+ byShift[s].workers+=cnt;
+ byShift[s].bill+=bill;
+ }
+ });
+ }
});
- // Headline pulse tile
- var headline=document.createElement('div');
- headline.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:12px 14px';
- var hdLabel=document.createElement('div');
- hdLabel.style.cssText='font-size:10px;color:#545d68;text-transform:uppercase;letter-spacing:1px;margin-bottom:4px';
- hdLabel.textContent='Chicago permit pulse — unclaimed demand';
- var hdBig=document.createElement('div');
- hdBig.style.cssText='font-size:18px;font-weight:700;color:#e6edf3;letter-spacing:-0.3px';
- hdBig.textContent=contracts.length+' permit'+(contracts.length===1?'':'s')+' · ~'+totalWorkers+' worker'+(totalWorkers===1?'':'s')+' needed';
- var hdSub=document.createElement('div');
- hdSub.style.cssText='font-size:11px;color:#8b949e;margin-top:3px';
- var hdParts=[];
- if(totalBillPerHr>0) hdParts.push('$'+totalBillPerHr.toFixed(0)+'/hr combined bill demand');
- if(urgencyMix.overdue>0) hdParts.push(urgencyMix.overdue+' overdue');
- if(urgencyMix.urgent>0) hdParts.push(urgencyMix.urgent+' urgent');
- hdSub.textContent=hdParts.length?hdParts.join(' · '):'scheduled only — no emergencies on the board';
- headline.appendChild(hdLabel);headline.appendChild(hdBig);headline.appendChild(hdSub);
- rightCol.appendChild(headline);
+ // Rolling bill-rate counter — the headline number is unique to the
+ // right side (counts/coverage already live in the left console as
+ // dials). Animates from 0 to target on first paint with an ease-out
+ // cubic, then jitters ±0.8% every 3s so it reads as a live market
+ // signal rather than a static figure. Mono digits + green LED glow
+ // matches the digital-clock aesthetic across the row.
+ var rateBox=document.createElement('div');
+ rateBox.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:14px 18px';
+ var rateLbl=document.createElement('div');
+ rateLbl.style.cssText='font-size:10px;color:#545d68;text-transform:uppercase;letter-spacing:1px;margin-bottom:6px';
+ rateLbl.textContent='Combined bill demand · if every open permit fills';
+ var rateBig=document.createElement('div');
+ rateBig.id='lh-rate-big';
+ rateBig.style.cssText='font-size:32px;font-weight:700;color:#3fb950;letter-spacing:-0.4px;font-variant-numeric:tabular-nums;line-height:1.1;font-family:"SF Mono","Menlo","Consolas",monospace;text-shadow:0 0 8px rgba(63,185,80,0.45),0 0 2px rgba(63,185,80,0.7)';
+ rateBig.textContent='$0/hr';
+ var rateSub=document.createElement('div');
+ rateSub.style.cssText='font-size:11px;color:#8b949e;margin-top:6px';
+ rateSub.textContent='Aggregate across '+contracts.length+' open permit'+(contracts.length===1?'':'s')+' · revenue if 100% filled';
+ rateBox.appendChild(rateLbl);rateBox.appendChild(rateBig);rateBox.appendChild(rateSub);
+ rightCol.appendChild(rateBox);
- // Per-shift 2x2 grid
- var shiftGrid=document.createElement('div');
- shiftGrid.style.cssText='display:grid;grid-template-columns:repeat(2,1fr);gap:8px';
- ['1st','2nd','3rd','4th'].forEach(function(s){
+ // Animate the rate from 0 → target on first paint, then jitter to
+ // simulate live signal. The target lives on rateBig.dataset.target
+ // so the shift-mix click handler can reassign it without restarting
+ // the animation loop. Jitter interval reads dataset on every fire.
+ rateBig.dataset.target=String(Math.round(totalBillPerHr));
+ if(parseFloat(rateBig.dataset.target)>0){
+ var rateStartT=performance.now();
+ function animateRate(){
+ if(!document.body.contains(rateBig)) return;
+ var dt=performance.now()-rateStartT;
+ var p=Math.min(1,dt/1200);
+ var eased=1-Math.pow(1-p,3);
+ var target=parseFloat(rateBig.dataset.target)||0;
+ var val=Math.floor(target*eased);
+ rateBig.textContent='$'+val.toLocaleString()+'/hr';
+ if(p<1) requestAnimationFrame(animateRate);
+ else {
+ var iv=setInterval(function(){
+ if(!document.body.contains(rateBig)){clearInterval(iv);return}
+ var t=parseFloat(rateBig.dataset.target)||0;
+ if(t<=0){ rateBig.textContent='—'; return; }
+ var jittered=t+Math.round((Math.random()-0.5)*t*0.016);
+ rateBig.textContent='$'+jittered.toLocaleString()+'/hr';
+ },3000);
+ }
+ }
+ requestAnimationFrame(animateRate);
+ } else {
+ rateBig.textContent='—';
+ rateBig.style.color='#545d68';
+ }
+
+ // Deadline pressure meter — the right side's other unique piece.
+ // Each urgency tier gets a count + small color-coded pill. If
+ // there's any overdue or urgent, the row gets a pulsing red dot
+ // accent at the left edge.
+ var urgBox=document.createElement('div');
+ urgBox.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:12px 16px';
+ var urgLbl=document.createElement('div');
+ urgLbl.style.cssText='font-size:10px;color:#545d68;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px;display:flex;align-items:center;gap:6px';
+ var hasHot=(urgencyMix.overdue||0)+(urgencyMix.urgent||0)>0;
+ if(hasHot){
+ var alertDot=document.createElement('span');
+ alertDot.style.cssText='display:inline-block;width:6px;height:6px;border-radius:50%;background:#f85149;animation:lh-pulse 1.1s ease-in-out infinite;box-shadow:0 0 6px #f85149';
+ urgLbl.appendChild(alertDot);
+ }
+ urgLbl.appendChild(document.createTextNode('Deadline pressure · open permits'));
+ urgBox.appendChild(urgLbl);
+ var urgRow=document.createElement('div');
+ urgRow.style.cssText='display:flex;gap:18px;align-items:baseline;flex-wrap:wrap';
+ var urgDefs=[
+ {key:'overdue', color:'#f85149', label:'overdue'},
+ {key:'urgent', color:'#f5894a', label:'urgent'},
+ {key:'soon', color:'#d29922', label:'soon'},
+ {key:'scheduled',color:'#8b949e', label:'scheduled'},
+ ];
+ urgDefs.forEach(function(u){
var cell=document.createElement('div');
- cell.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:8px;padding:8px 10px;border-left:3px solid '+SHIFT_COLORS[s]+(cs===s?';box-shadow:0 0 0 1px '+SHIFT_COLORS[s]:'');
- var head=document.createElement('div');head.style.cssText='font-size:9px;color:'+SHIFT_COLORS[s]+';font-weight:600;text-transform:uppercase;letter-spacing:1px;margin-bottom:2px';
- head.textContent=s+' shift'+(cs===s?' · live':'');
- var big=document.createElement('div');big.style.cssText='font-size:16px;font-weight:600;color:#e6edf3';big.textContent=shiftCounts[s]+' permit'+(shiftCounts[s]===1?'':'s');
- cell.appendChild(head);cell.appendChild(big);
- shiftGrid.appendChild(cell);
+ cell.style.cssText='display:flex;flex-direction:column;align-items:flex-start';
+ var v=document.createElement('span');
+ v.style.cssText='font-size:22px;font-weight:700;color:'+u.color+';font-variant-numeric:tabular-nums;letter-spacing:-0.4px;line-height:1';
+ v.textContent=urgencyMix[u.key]||0;
+ var l=document.createElement('span');
+ l.style.cssText='font-size:9px;color:#6e7681;text-transform:uppercase;letter-spacing:1px;margin-top:4px';
+ l.textContent=u.label;
+ cell.appendChild(v);cell.appendChild(l);
+ urgRow.appendChild(cell);
});
- rightCol.appendChild(shiftGrid);
+ urgBox.appendChild(urgRow);
+ rightCol.appendChild(urgBox);
// Role mix — top 4 roles
var roleEntries=Object.keys(roleMix).map(function(k){return [k,roleMix[k]];}).sort(function(a,b){return b[1]-a[1];}).slice(0,4);
@@ -817,6 +1546,132 @@ function loadLiveMarket(contracts){
rightCol.appendChild(roleBox);
}
+ // Shift mix — visual comparison of permit demand across the four
+ // standard shifts, plus interactive previewing. Each shift is a
+ // pseudo-button: a bar-chart bar (height ∝ permit count) with the
+ // count printed on it. Clicking a button updates the left console's
+ // shift badge so the user can preview "what does this shift look
+ // like" — labeled "live" when it matches real time, "preview"
+ // otherwise. The currently-active shift is highlighted via a
+ // colored border and a deeper fill opacity.
+ var shiftMixBox=document.createElement('div');
+ shiftMixBox.style.cssText='background:#0d1117;border:1px solid #171d27;border-radius:10px;padding:12px 16px';
+ var shiftMixHeader=document.createElement('div');
+ shiftMixHeader.style.cssText='font-size:10px;color:#545d68;text-transform:uppercase;letter-spacing:1px;margin-bottom:10px;display:flex;justify-content:space-between;align-items:center';
+ var shiftMixTitle=document.createElement('span');
+ shiftMixTitle.textContent='Shift mix · click to preview';
+ var shiftMixHint=document.createElement('span');
+ shiftMixHint.style.cssText='font-size:9px;color:#6e7681;font-weight:400';
+ shiftMixHint.textContent='live now · '+cs;
+ shiftMixHeader.appendChild(shiftMixTitle);
+ shiftMixHeader.appendChild(shiftMixHint);
+ shiftMixBox.appendChild(shiftMixHeader);
+
+ var shiftMixGrid=document.createElement('div');
+ shiftMixGrid.style.cssText='display:grid;grid-template-columns:repeat(4,1fr);gap:6px';
+ var maxShiftCount=Math.max(1,
+ shiftCounts['1st']||0, shiftCounts['2nd']||0,
+ shiftCounts['3rd']||0, shiftCounts['4th']||0);
+
+ ['1st','2nd','3rd','4th'].forEach(function(s){
+ var cnt=shiftCounts[s]||0;
+ var hoursLabel={'1st':'06–14','2nd':'14–22','3rd':'22–06','4th':'wknd'}[s];
+ var btn=document.createElement('button');
+ btn.dataset.shift=s;
+ var isActive=(s===cs);
+ var pctHeight=Math.round((cnt/maxShiftCount)*100);
+ btn.style.cssText='background:#161b22;border:1px solid '+(isActive?SHIFT_COLORS[s]:'#1a2030')+';border-radius:6px;padding:8px 6px 8px;cursor:pointer;text-align:center;display:flex;flex-direction:column;align-items:stretch;gap:4px;font-family:inherit;color:#e6edf3;outline:none;position:relative;overflow:hidden;transition:border-color 200ms,background 200ms;min-height:78px';
+
+ // Background bar — height proportional to count, color = shift.
+ var fillBar=document.createElement('div');
+ fillBar.className='shift-fill';
+ fillBar.style.cssText='position:absolute;left:0;right:0;bottom:0;height:'+pctHeight+'%;background:'+SHIFT_COLORS[s]+';opacity:'+(isActive?0.22:0.08)+';transition:opacity 200ms;pointer-events:none';
+ btn.appendChild(fillBar);
+
+ // Shift label
+ var nameRow=document.createElement('div');
+ nameRow.style.cssText='font-size:10px;color:'+SHIFT_COLORS[s]+';font-weight:700;letter-spacing:1.5px;text-transform:uppercase;position:relative;line-height:1';
+ nameRow.textContent=s;
+ btn.appendChild(nameRow);
+
+ // Big count
+ var ctRow=document.createElement('div');
+ ctRow.style.cssText='font-size:20px;font-weight:700;color:#e6edf3;letter-spacing:-0.3px;font-variant-numeric:tabular-nums;line-height:1;position:relative;margin-top:2px';
+ ctRow.textContent=cnt;
+ btn.appendChild(ctRow);
+
+ // Hours range (sub)
+ var subRow=document.createElement('div');
+ subRow.style.cssText='font-size:9px;color:#6e7681;letter-spacing:1px;position:relative;margin-top:auto;font-variant-numeric:tabular-nums';
+ subRow.textContent=hoursLabel;
+ btn.appendChild(subRow);
+
+ btn.onclick=function(){
+ var shift=this.dataset.shift;
+ var liveNow=(shift===cs);
+ // Update the left console's shift badge — color + label change.
+ shiftBadge.textContent=shift+' shift '+(liveNow?'live':'preview');
+ shiftBadge.style.color=SHIFT_COLORS[shift];
+ // Update sibling buttons' active state.
+ shiftMixGrid.querySelectorAll('button').forEach(function(b){
+ var s2=b.dataset.shift;
+ var isAct=(s2===shift);
+ b.style.borderColor=isAct?SHIFT_COLORS[s2]:'#1a2030';
+ var bar=b.querySelector('.shift-fill');
+ if(bar) bar.style.opacity=isAct?0.22:0.08;
+ });
+ // Re-slice the metrics: dials + bill rate now reflect THIS
+ // shift's data only. A repeated click on the same shift toggles
+ // back to the all-shifts total so the user can compare.
+ var sd=byShift[shift]||{permits:0,workers:0,bill:0};
+ var isToggleOff=(window._lhActiveShift===shift);
+ var view = isToggleOff
+ ? { permits:contracts.length, workers:totalWorkers, bill:totalBillPerHr, label:'all shifts' }
+ : { permits:sd.permits, workers:sd.workers, bill:sd.bill, label:shift+' shift only' };
+ window._lhActiveShift = isToggleOff ? null : shift;
+
+ // Push values into the dial gauges. setDial scales each gauge's
+ // arc to the new value and updates the center number; the
+ // ticker (lhTick) keeps wobbling against whatever value lands.
+ function setDial(id,value,max){
+ var arc=document.getElementById(id+'-arc');
+ var val=document.getElementById(id+'-val');
+ if(!arc||!val) return;
+ val.textContent=value;
+ arc.dataset.max=max;
+ var c=parseFloat(arc.dataset.circ);
+ arc.setAttribute('stroke-dashoffset',c*(1-Math.min(1,value/max)));
+ }
+ var benchEst=Math.floor(view.permits*45)||50;
+ var coverage=view.workers>0?Math.min(100,Math.floor(benchEst/view.workers*100)):100;
+ setDial('d-permits',view.permits, Math.max(50, view.permits*1.5));
+ setDial('d-workers',view.workers, Math.max(300, view.workers*1.5));
+ setDial('d-bench', benchEst, Math.max(1500, benchEst*1.5));
+ setDial('d-fill', coverage, 100);
+
+ // Bill rate counter — push new target into dataset so the
+ // existing jitter loop picks it up and the shown value tracks.
+ rateBig.dataset.target=String(Math.round(view.bill));
+ rateBig.textContent='$'+Math.round(view.bill).toLocaleString()+'/hr';
+ rateSub.textContent=view.permits+' permit'+(view.permits===1?'':'s')+' · '+view.label;
+
+ // If we toggled off, clear all active borders so the buttons
+ // visually read as "no shift selected · viewing all".
+ if(isToggleOff){
+ shiftMixGrid.querySelectorAll('button').forEach(function(b){
+ b.style.borderColor='#1a2030';
+ var bar=b.querySelector('.shift-fill');
+ if(bar) bar.style.opacity=0.08;
+ });
+ shiftBadge.textContent='all shifts · live='+cs;
+ shiftBadge.style.color='#8b949e';
+ }
+ };
+ shiftMixGrid.appendChild(btn);
+ });
+ shiftMixBox.appendChild(shiftMixGrid);
+ rightCol.appendChild(shiftMixBox);
+
root.appendChild(rightCol);
}
@@ -2124,13 +2979,28 @@ function showProfile(workerData){
body.appendChild(tgs);
}
- // Certifications
+ // Certifications — pill text plus a server-rendered icon (cert
+ // badge / placard photo). Icon URL resolves the raw cert string
+ // server-side via /icons/cert?text=... → 404 if no recipe matches,
+ // in which case onerror drops the img and the pill falls back to
+ // text-only. No client-side slug knowledge required.
if(workerData.certs&&workerData.certs.length){
addSection(body,'Certifications','');
- var cgs=document.createElement('div');cgs.style.cssText='display:flex;gap:6px;flex-wrap:wrap;margin-bottom:20px';
+ var cgs=document.createElement('div');cgs.style.cssText='display:flex;gap:8px;flex-wrap:wrap;margin-bottom:20px';
workerData.certs.forEach(function(c){
- var t=document.createElement('span');t.style.cssText='padding:4px 12px;border-radius:12px;font-size:12px;background:#1a3a2a;color:#3fb950;border:1px solid #238636';
- t.textContent=c.trim();cgs.appendChild(t);
+ var label=c.trim(); if(!label) return;
+ var t=document.createElement('span');t.style.cssText='padding:4px 10px 4px 6px;border-radius:14px;font-size:12px;background:#1a3a2a;color:#3fb950;border:1px solid #238636;display:inline-flex;align-items:center;gap:6px';
+ var ic=document.createElement('img');
+ ic.src=P+'/icons/cert?text='+encodeURIComponent(label)+'&v='+(window.ICONS_VERSION||'v1');
+ ic.alt='';ic.className='cert-icon';
+ // 14px display + 256² source = ~18× downsample, packing detail
+ // into each pixel. Smaller than the surrounding text feels
+ // intentional, like an inline glyph rather than a thumbnail.
+ ic.style.cssText='width:14px;height:14px;border-radius:50%;object-fit:cover;background:#0d1117';
+ ic.onerror=function(){this.remove()};
+ t.appendChild(ic);
+ var tx=document.createElement('span');tx.textContent=label;t.appendChild(tx);
+ cgs.appendChild(t);
});
body.appendChild(cgs);
}
@@ -2438,25 +3308,10 @@ function addWorkerInsight(parent,name,detail,why,idx,highlight){
// so the gender + ethnicity guesses are confident — the face-pool
// selector uses both. Once deepface tags the pool the server will
// narrow accordingly; until then it falls back to the full pool but
- // the URL shape is forward-compatible.
- var faceKey = (workerDataRef && (workerDataRef.candidate_id || workerDataRef.doc_id)) || name || '';
- var nameParts = (name||'').trim().split(/\s+/);
- var firstName = nameParts[0]||'';
- var lastName = nameParts.length > 1 ? nameParts[nameParts.length-1] : '';
- var gHint = genderFor(firstName);
- var eHint = guessEthnicityFromName(firstName, lastName);
- if(faceKey){
- var img=document.createElement('img');
- img.alt='';
- // No lazy-loading: thumbs are 384x384 webp (~11KB) so eager
- // load is cheap (~500KB for 50 cards). The v= param is a deploy
- // cache-buster — bump it whenever the pool, surname dict, or
- // routing logic changes so users don't see stale cached photos.
- var qs = '?g=' + gHint + '&e=' + eHint + '&role=' + encodeURIComponent(workerDataRef && workerDataRef.role || 'warehouse worker') + '&v=2';
- img.src = P + '/headshots/' + encodeURIComponent(faceKey) + qs;
- img.onerror=function(){ this.remove(); };
- av.appendChild(img);
- }
+ // Headshot insertion removed 2026-04-28 per user request — face
+ // generation is being handled outside this codebase. The .av div
+ // continues to display monogram initials (set above) as the avatar.
+ // Backend /headshots/ routes are kept dormant for future re-enable.
w.appendChild(av);
var info=document.createElement('div');info.className='info';
var nm=document.createElement('div');nm.className='nm';nm.textContent=name;