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;