[object Object]

← back to Dw Activation Calendar

Add two-tab DW Calendar UI: editable Google Calendar modal (create/edit/delete via modal-rig) + preserved SKU Activation grid; graceful not-connected state, event chips show created date+time

549858278a05d9c2037df6f0431c517bc4b39e17 · 2026-06-24 13:42:47 -0700 · Steve

Files touched

Diff

commit 549858278a05d9c2037df6f0431c517bc4b39e17
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jun 24 13:42:47 2026 -0700

    Add two-tab DW Calendar UI: editable Google Calendar modal (create/edit/delete via modal-rig) + preserved SKU Activation grid; graceful not-connected state, event chips show created date+time
---
 public/index.html   | 255 +++++++++++++++++++++++++++++++++++++++++++++++-----
 public/modal-rig.js | 155 ++++++++++++++++++++++++++++++++
 2 files changed, 387 insertions(+), 23 deletions(-)

diff --git a/public/index.html b/public/index.html
index 6ab7e10..d65886f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -3,11 +3,12 @@
 <head>
 <meta charset="utf-8">
 <meta name="viewport" content="width=device-width, initial-scale=1">
-<title>DW Activation Calendar</title>
+<title>DW Calendar</title>
 <style>
   :root{
     --bg:#0e0f12; --panel:#15171c; --panel2:#1b1e25; --line:#2a2e38;
     --ink:#ece8df; --ink-2:#b8b3a6; --ink-3:#7c7768; --gold:#c2a064; --gold-dim:#8a7240;
+    --card:#16181d; --border:#2a2e38;
   }
   *{box-sizing:border-box}
   body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
@@ -23,6 +24,20 @@
   button:hover{background:var(--gold)}
   button.ghost{background:none;border:1px solid var(--line);color:var(--ink-2)}
   button.ghost:hover{color:var(--ink);border-color:var(--gold-dim)}
+  button.danger{background:#7a3a3a;color:#f3e6e6}
+  button.danger:hover{background:#9b4848}
+  /* tabs */
+  .tabs{display:flex;gap:4px;padding:0 22px;border-bottom:1px solid var(--line);background:var(--bg);position:sticky;top:53px;z-index:4}
+  .tab{background:none;border:none;border-bottom:2px solid transparent;color:var(--ink-3);font-weight:600;padding:11px 16px;cursor:pointer;font-size:13px;border-radius:0}
+  .tab:hover{color:var(--ink-2);background:none}
+  .tab.active{color:var(--gold);border-bottom-color:var(--gold)}
+  .view{display:none}
+  .view.active{display:block}
+  /* connection banner */
+  .banner{margin:14px 22px;padding:11px 16px;border-radius:9px;border:1px solid var(--gold-dim);background:rgba(194,160,100,.08);color:var(--ink-2);font-size:13px;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
+  .banner.ok{border-color:#3c6d3c;background:rgba(109,179,109,.08)}
+  .banner b{color:var(--ink)}
+  /* SKU view (existing) */
   .summary{display:flex;gap:22px;padding:10px 22px;border-bottom:1px solid var(--line);font-size:12px;color:var(--ink-2);flex-wrap:wrap}
   .summary b{color:var(--ink);font-size:15px}
   .summary .k{display:flex;flex-direction:column;gap:1px}
@@ -49,53 +64,243 @@
   .legend .sw{width:10px;height:10px;border-radius:3px;display:inline-block}
   .note{padding:0 22px 10px;font-size:11px;color:var(--ink-3)}
   .loading{padding:60px;text-align:center;color:var(--ink-3)}
+  /* Google cal events */
+  .gcell .ev{display:block;width:100%;text-align:left;background:var(--panel2);border:1px solid var(--gold-dim);border-radius:5px;color:var(--ink);font-size:10px;line-height:1.3;padding:3px 5px;margin:0 0 4px;cursor:pointer;overflow:hidden}
+  .gcell .ev:hover{border-color:var(--gold);background:#23262e}
+  .gcell .ev .et{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+  .gcell .ev .ewhen{color:var(--gold)}
+  .gcell .ev .ecreated{color:var(--ink-3);font-size:8px;margin-top:1px}
+  .gcell .addbtn{margin:4px 0 0;font-size:10px;padding:2px 8px;background:none;border:1px dashed var(--line);color:var(--ink-3)}
+  .gcell .addbtn:hover{border-color:var(--gold-dim);color:var(--ink-2);background:none}
+  /* modal form */
+  .mform label{display:block;font-size:11px;color:var(--ink-3);text-transform:uppercase;letter-spacing:.5px;margin:12px 0 4px}
+  .mform input[type=text],.mform textarea,.mform input[type=datetime-local],.mform input[type=date]{
+    width:100%;background:var(--panel2);border:1px solid var(--line);border-radius:6px;color:var(--ink);font:inherit;font-size:13px;padding:8px 10px}
+  .mform textarea{min-height:64px;resize:vertical}
+  .mform .row{display:flex;gap:12px}.mform .row>div{flex:1}
+  .mform .chk{display:flex;align-items:center;gap:8px;margin-top:12px;font-size:13px;color:var(--ink-2)}
+  .mform .actions{display:flex;justify-content:space-between;align-items:center;margin-top:18px;gap:10px}
+  .mform .msg{font-size:12px;min-height:16px}
+  .mform .msg.err{color:#e08a8a}.mform .msg.ok{color:#8fd08f}
 </style>
 </head>
 <body>
 <header>
   <div>
     <div class="sub">Designer Wallcoverings</div>
-    <div class="wordmark">Activation Calendar<span class="dot">.</span></div>
+    <div class="wordmark">DW Calendar<span class="dot">.</span></div>
   </div>
   <div class="spacer"></div>
-  <div class="ctrl"><label>Per day</label><input id="perDay" type="number" value="500" min="1"></div>
-  <div class="ctrl"><label>Per vendor/day</label><input id="perVendorDay" type="number" value="500" min="1"></div>
-  <div class="ctrl"><label>Start</label><input id="start" type="date"></div>
-  <button id="apply">Apply</button>
+  <!-- SKU-view controls (only shown on the SKU tab) -->
+  <div id="skuCtrls" style="display:none;gap:18px">
+    <div class="ctrl"><label>Per day</label><input id="perDay" type="number" value="500" min="1"></div>
+    <div class="ctrl"><label>Per vendor/day</label><input id="perVendorDay" type="number" value="500" min="1"></div>
+    <div class="ctrl"><label>Start</label><input id="start" type="date"></div>
+    <button id="apply">Apply</button>
+  </div>
 </header>
 
-<div class="summary" id="summary"></div>
-<div class="note">Projected go-active schedule for every SKU staged for activation — published at <b>500 variants/day</b> (the 5-field backlog half of the 1,000/day cap), round-robin breadth-first across all vendors, in date order, until the list is cleared. Each chip is a SKU: product image + vendor. <span style="color:#6db36d">●</span> sample-add · <span style="color:#c2a064">●</span> roll/price build.</div>
+<div class="tabs">
+  <button class="tab" id="tab-gcal" data-view="gcal">Google Calendar</button>
+  <button class="tab" id="tab-sku" data-view="sku">SKU Activation</button>
+</div>
 
-<div class="monthbar">
-  <button class="ghost" id="prev">‹ Prev</button>
-  <div class="ml" id="mlabel">—</div>
-  <button class="ghost" id="next">Next ›</button>
+<!-- ====================== GOOGLE CALENDAR VIEW ====================== -->
+<div class="view" id="view-gcal">
+  <div class="banner" id="gbanner">Checking calendar connection…</div>
+  <div class="monthbar">
+    <button class="ghost" id="gprev">‹ Prev</button>
+    <div class="ml" id="gmlabel">—</div>
+    <button class="ghost" id="gnext">Next ›</button>
+    <button id="gnew" style="margin-left:18px">+ New Event</button>
+  </div>
+  <div id="gcal"><div class="loading">Loading events…</div></div>
+  <div class="note">Live, editable view of the shared DW Google Calendar. Click any day or an event to create / edit. Events are written straight to Google Calendar (events.insert / patch / delete).</div>
 </div>
 
-<div id="cal"><div class="loading">Loading schedule…</div></div>
-<div class="legend" id="legend"></div>
+<!-- ====================== SKU ACTIVATION VIEW (existing) ====================== -->
+<div class="view" id="view-sku">
+  <div class="summary" id="summary"></div>
+  <div class="note">Projected go-active schedule for every SKU staged for activation — published at <b>500 variants/day</b> (the 5-field backlog half of the 1,000/day cap), round-robin breadth-first across all vendors, in date order, until the list is cleared. Each chip is a SKU: product image + vendor. <span style="color:#6db36d">●</span> sample-add · <span style="color:#c2a064">●</span> roll/price build.</div>
+  <div class="monthbar">
+    <button class="ghost" id="prev">‹ Prev</button>
+    <div class="ml" id="mlabel">—</div>
+    <button class="ghost" id="next">Next ›</button>
+  </div>
+  <div id="cal"><div class="loading">Loading schedule…</div></div>
+  <div class="legend" id="legend"></div>
+</div>
 
+<script src="/modal-rig.js"></script>
 <script>
-let DATA=null, viewMonth=null; // viewMonth = Date of 1st of shown month
+/* ======================= tabs ======================= */
+function setTab(v){
+  document.querySelectorAll('.tab').forEach(t=>t.classList.toggle('active',t.dataset.view===v));
+  document.getElementById('view-gcal').classList.toggle('active',v==='gcal');
+  document.getElementById('view-sku').classList.toggle('active',v==='sku');
+  document.getElementById('skuCtrls').style.display = v==='sku' ? 'flex':'none';
+  localStorage.setItem('dwcal_tab',v);
+  if(v==='gcal' && !GDATA) loadGcal();
+  if(v==='sku' && !DATA) load();
+}
+document.querySelectorAll('.tab').forEach(t=>t.onclick=()=>setTab(t.dataset.view));
+
+/* ======================= GOOGLE CALENDAR ======================= */
+let GDATA=null, gMonth=null, GSTATUS={connected:false,oauth_configured:false};
+function fmtMonth(d){return d.toLocaleString(undefined,{month:'long',year:'numeric'});}
+function fmtCreated(iso){
+  if(!iso) return '';
+  try{return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});}
+  catch(e){return iso;}
+}
+function fmtWhen(ev){
+  if(ev.allDay) return 'All day';
+  try{return new Date(ev.start).toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'});}
+  catch(e){return '';}
+}
+async function refreshStatus(){
+  try{ GSTATUS = await (await fetch('/api/calendar/status')).json(); }
+  catch(e){ GSTATUS={connected:false,oauth_configured:false,error:e.message}; }
+  const b=document.getElementById('gbanner');
+  if(GSTATUS.connected){
+    b.className='banner ok';
+    b.innerHTML='<b>● Connected</b> — editing calendar <code>'+(GSTATUS.calendar_id||'primary')+'</code>. '+
+      '<button class="ghost" id="gdisc">Disconnect</button>';
+    document.getElementById('gdisc').onclick=async()=>{await fetch('/api/oauth/revoke',{method:'POST'});refreshStatus();loadGcal();};
+  }else{
+    b.className='banner';
+    b.innerHTML='<b>Calendar not connected — Steve must authorize.</b> '+
+      (GSTATUS.oauth_configured
+        ? 'OAuth is configured — click to grant access. '
+        : 'The Google OAuth credential has not been added yet (queued in pending-approval). ')+
+      '<a href="/api/oauth/start"><button>Connect</button></a>';
+  }
+}
+async function loadGcal(){
+  await refreshStatus();
+  if(!gMonth){const n=new Date();gMonth=new Date(n.getFullYear(),n.getMonth(),1);}
+  document.getElementById('gmlabel').textContent=fmtMonth(gMonth);
+  document.getElementById('gcal').innerHTML='<div class="loading">Loading events…</div>';
+  const y=gMonth.getFullYear(), m=gMonth.getMonth();
+  const timeMin=new Date(y,m,1).toISOString(), timeMax=new Date(y,m+1,0,23,59,59).toISOString();
+  let resp={connected:false,events:[]};
+  try{ resp=await (await fetch('/api/events?timeMin='+encodeURIComponent(timeMin)+'&timeMax='+encodeURIComponent(timeMax))).json(); }
+  catch(e){ resp={connected:false,events:[],error:e.message}; }
+  GDATA=resp;
+  renderGcal();
+}
+function renderGcal(){
+  const byDate={};
+  (GDATA.events||[]).forEach(ev=>{ const d=(ev.start||'').slice(0,10); (byDate[d]=byDate[d]||[]).push(ev); });
+  const y=gMonth.getFullYear(), m=gMonth.getMonth();
+  const cal=document.getElementById('gcal'); cal.className='cal'; cal.innerHTML='';
+  ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d=>{const h=document.createElement('div');h.className='dow';h.textContent=d;cal.appendChild(h);});
+  const first=new Date(y,m,1), startDow=first.getDay(), days=new Date(y,m+1,0).getDate();
+  for(let i=0;i<startDow;i++){const c=document.createElement('div');c.className='cell empty';cal.appendChild(c);}
+  for(let dn=1;dn<=days;dn++){
+    const iso=`${y}-${String(m+1).padStart(2,'0')}-${String(dn).padStart(2,'0')}`;
+    const cell=document.createElement('div'); cell.className='cell gcell';
+    const evs=byDate[iso]||[];
+    cell.innerHTML=`<div class="dh"><span class="dn">${dn}</span>${evs.length?`<span class="cnt">${evs.length}</span>`:''}</div>`;
+    const body=document.createElement('div'); body.className='chips'; body.style.gridTemplateColumns='1fr'; body.style.display='block';
+    evs.forEach(ev=>{
+      const b=document.createElement('button'); b.className='ev';
+      b.innerHTML=`<div class="et">${esc(ev.summary)}</div>`+
+        `<div class="ewhen">${fmtWhen(ev)}</div>`+
+        `<div class="ecreated" title="${ev.created||''}">🕓 ${fmtCreated(ev.created)}</div>`;
+      b.onclick=(e)=>{e.stopPropagation();openEventModal(ev,iso);};
+      body.appendChild(b);
+    });
+    const add=document.createElement('button'); add.className='addbtn'; add.textContent='+ add';
+    add.onclick=(e)=>{e.stopPropagation();openEventModal(null,iso);};
+    body.appendChild(add);
+    cell.appendChild(body);
+    cal.appendChild(cell);
+  }
+}
+function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));}
+
+/* ---- the editable modal (create + edit) ---- */
+function toLocalInput(iso){ // ISO -> value for datetime-local (local tz)
+  if(!iso) return '';
+  const d=new Date(iso); if(isNaN(d)) return '';
+  const p=n=>String(n).padStart(2,'0');
+  return `${d.getFullYear()}-${p(d.getMonth()+1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
+}
+function openEventModal(ev, dayISO){
+  const isEdit=!!ev;
+  const startVal = ev ? (ev.allDay ? '' : toLocalInput(ev.start)) : (dayISO+'T09:00');
+  const endVal   = ev ? (ev.allDay ? '' : toLocalInput(ev.end))   : (dayISO+'T10:00');
+  const allDay = ev ? !!ev.allDay : false;
+  const bodyHtml=`<form class="mform" id="evForm" onsubmit="return false">
+    <label>Title *</label><input type="text" id="f_summary" value="${esc(ev?ev.summary:'')}" placeholder="Event title" required>
+    <label>Description</label><textarea id="f_desc" placeholder="Notes">${esc(ev?ev.description:'')}</textarea>
+    <label>Location</label><input type="text" id="f_loc" value="${esc(ev?ev.location:'')}" placeholder="Optional">
+    <label class="chk"><input type="checkbox" id="f_allday" ${allDay?'checked':''}> All-day event</label>
+    <div class="row" id="timedRow" style="${allDay?'display:none':''}">
+      <div><label>Start</label><input type="datetime-local" id="f_start" value="${startVal}"></div>
+      <div><label>End</label><input type="datetime-local" id="f_end" value="${endVal}"></div>
+    </div>
+    <div class="row" id="dayRow" style="${allDay?'':'display:none'}">
+      <div><label>Date</label><input type="date" id="f_date" value="${(ev?ev.start:dayISO||'').slice(0,10)}"></div>
+    </div>
+    <div class="msg" id="f_msg"></div>
+    <div class="actions">
+      <div>${isEdit?'<button type="button" class="danger" id="f_del">Delete</button>':''}</div>
+      <div><button type="button" class="ghost" id="f_cancel">Cancel</button> <button type="button" id="f_save">${isEdit?'Save':'Create'}</button></div>
+    </div>
+  </form>`;
+  const M=ModalRig.open({key:'dwcal-event',title:isEdit?'Edit Event':'New Event',bodyHtml,width:440,height:520,bg:'var(--card)'});
+  const $=id=>M.body.querySelector('#'+id);
+  $('f_allday').onchange=()=>{const on=$('f_allday').checked;$('timedRow').style.display=on?'none':'';$('dayRow').style.display=on?'':'none';};
+  $('f_cancel').onclick=M.close;
+  if(isEdit) $('f_del').onclick=async()=>{
+    if(!confirm('Delete this event?')) return;
+    const r=await fetch('/api/events/'+encodeURIComponent(ev.id),{method:'DELETE'});
+    const j=await r.json().catch(()=>({}));
+    if(r.ok && j.ok){M.close();loadGcal();} else showMsg($,'Delete failed'+(j.error?': '+j.error:''),true);
+  };
+  $('f_save').onclick=async()=>{
+    const allday=$('f_allday').checked;
+    const payload={summary:$('f_summary').value.trim(),description:$('f_desc').value,location:$('f_loc').value,allDay:allday};
+    if(!payload.summary){showMsg($,'Title is required',true);return;}
+    if(allday){const d=$('f_date').value;if(!d){showMsg($,'Date required',true);return;}payload.start=d;payload.end=d;}
+    else{
+      const s=$('f_start').value,e=$('f_end').value;
+      if(!s||!e){showMsg($,'Start and end required',true);return;}
+      payload.start=new Date(s).toISOString();payload.end=new Date(e).toISOString();
+    }
+    showMsg($,'Saving…',false);
+    const url=isEdit?'/api/events/'+encodeURIComponent(ev.id):'/api/events';
+    const r=await fetch(url,{method:isEdit?'PATCH':'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
+    const j=await r.json().catch(()=>({}));
+    if(r.status===409){showMsg($,'Calendar not connected — Steve must authorize.',true);return;}
+    if(r.ok && j.ok){M.close();loadGcal();}
+    else showMsg($,'Save failed'+(j.error?': '+j.error:'')+(j.detail?' — '+j.detail:''),true);
+  };
+}
+function showMsg($,t,err){const m=$('f_msg');m.textContent=t;m.className='msg '+(err?'err':'ok');}
+
+document.getElementById('gprev').onclick=()=>{gMonth=new Date(gMonth.getFullYear(),gMonth.getMonth()-1,1);loadGcal();};
+document.getElementById('gnext').onclick=()=>{gMonth=new Date(gMonth.getFullYear(),gMonth.getMonth()+1,1);loadGcal();};
+document.getElementById('gnew').onclick=()=>{const n=new Date();openEventModal(null,n.toISOString().slice(0,10));};
+
+/* ======================= SKU ACTIVATION (existing, unchanged logic) ======================= */
+let DATA=null, viewMonth=null;
 const VCOLORS={};
 function vcolor(v){
   if(VCOLORS[v]) return VCOLORS[v];
   let h=0; for(const c of v) h=(h*31+c.charCodeAt(0))%360;
   return VCOLORS[v]=`hsl(${h},42%,58%)`;
 }
-const PROXY=u=>u; // shopify CDN loads direct
-function fmtMonth(d){return d.toLocaleString(undefined,{month:'long',year:'numeric'});}
-
 async function load(){
   const perDay=document.getElementById('perDay').value||500;
   const perVendorDay=document.getElementById('perVendorDay').value||500;
   const start=document.getElementById('start').value;
   const qs=new URLSearchParams({perDay,perVendorDay}); if(start) qs.set('start',start);
-  document.getElementById('cal').innerHTML='<div class="loading">Scheduling '+'…</div>';
+  document.getElementById('cal').innerHTML='<div class="loading">Scheduling …</div>';
   const r=await fetch('/api/schedule?'+qs); DATA=await r.json();
   renderSummary(); renderLegend();
-  // default view = month of first scheduled day
   const first=DATA.days[0]?new Date(DATA.days[0].date+'T00:00:00'):new Date();
   viewMonth=new Date(first.getFullYear(),first.getMonth(),1);
   renderMonth();
@@ -119,8 +324,7 @@ function renderMonth(){
   const byDate={}; DATA.days.forEach(d=>byDate[d.date]=d);
   const y=viewMonth.getFullYear(), m=viewMonth.getMonth();
   document.getElementById('mlabel').textContent=fmtMonth(viewMonth);
-  const cal=document.getElementById('cal'); cal.className=''; cal.innerHTML='';
-  cal.style.display='grid'; cal.style.gridTemplateColumns='repeat(7,1fr)'; cal.style.gap='8px'; cal.style.padding='0 22px 30px';
+  const cal=document.getElementById('cal'); cal.className='cal'; cal.innerHTML='';
   ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'].forEach(d=>{const h=document.createElement('div');h.className='dow';h.textContent=d;cal.appendChild(h);});
   const first=new Date(y,m,1), startDow=first.getDay(), days=new Date(y,m+1,0).getDate();
   for(let i=0;i<startDow;i++){const c=document.createElement('div');c.className='cell empty';cal.appendChild(c);}
@@ -150,7 +354,12 @@ function renderMonth(){
 document.getElementById('prev').onclick=()=>{viewMonth=new Date(viewMonth.getFullYear(),viewMonth.getMonth()-1,1);renderMonth();};
 document.getElementById('next').onclick=()=>{viewMonth=new Date(viewMonth.getFullYear(),viewMonth.getMonth()+1,1);renderMonth();};
 document.getElementById('apply').onclick=load;
-load();
+
+/* ======================= boot ======================= */
+// if landing via OAuth callback (?connected=1), open the gcal tab
+const initialTab = (new URLSearchParams(location.search).has('connected')) ? 'gcal'
+  : (localStorage.getItem('dwcal_tab') || 'gcal');
+setTab(initialTab);
 </script>
 </body>
 </html>
diff --git a/public/modal-rig.js b/public/modal-rig.js
new file mode 100644
index 0000000..a62c5f9
--- /dev/null
+++ b/public/modal-rig.js
@@ -0,0 +1,155 @@
+/*!
+ * modal-rig.js — make any modal/panel "fully done": drag-to-rearrange,
+ * stretch/resize, collapsible sections, maximize, all persisted to localStorage.
+ * Vanilla JS, zero deps, framework-agnostic. Exposes window.ModalRig.
+ *
+ *   ModalRig.rig(panelEl, { key:'mynote' })          // enhance an existing panel
+ *   ModalRig.section(headerEl, bodyEl, 'mynote:links') // collapsible section
+ *   var m = ModalRig.open({ key:'mynote', title:'Note', bodyHtml:'<p>…</p>' })
+ *
+ * Conventions:
+ *  - The panel is absolutely positioned; first child (or opts.handle) is the drag handle.
+ *  - resize:both gives a native corner handle; size + position persist under opts.key.
+ *  - A ⤢ maximize button is injected into the handle unless maximizable:false.
+ */
+(function () {
+  function lsGet(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch (e) { return null; } }
+  function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
+  function px(n) { return Math.round(n) + 'px'; }
+
+  function applyRect(panel, key, opts) {
+    var w = opts.width || 760;
+    var h = opts.height || Math.min(620, Math.round(window.innerHeight * 0.8));
+    var r = lsGet('modalrig:' + key);
+    if (r && r.w) {
+      panel.style.left = px(r.left || 0); panel.style.top = px(r.top || 0);
+      panel.style.width = px(r.w); panel.style.height = px(r.h);
+    } else {
+      panel.style.left = px(Math.max(0, (window.innerWidth - w) / 2));
+      panel.style.top = px(Math.max(0, (window.innerHeight - h) / 2));
+      panel.style.width = px(w); panel.style.height = px(h);
+    }
+  }
+
+  function saveRect(panel, key) {
+    if (panel.getAttribute('data-rig-max') === '1') return; // don't persist the maximized state
+    lsSet('modalrig:' + key, {
+      left: parseInt(panel.style.left) || 0, top: parseInt(panel.style.top) || 0,
+      w: panel.offsetWidth, h: panel.offsetHeight
+    });
+  }
+
+  function startDrag(e, panel, key) {
+    if (e.target.closest && e.target.closest('[data-rig-btn]')) return; // not when clicking header buttons
+    if (panel.getAttribute('data-rig-max') === '1') unmaximize(panel, key); // dragging exits maximize
+    var box = panel.getBoundingClientRect();
+    var dx = e.clientX - box.left, dy = e.clientY - box.top;
+    function move(ev) {
+      panel.style.left = px(Math.max(0, Math.min(window.innerWidth - 80, ev.clientX - dx)));
+      panel.style.top = px(Math.max(0, Math.min(window.innerHeight - 40, ev.clientY - dy)));
+    }
+    function up() { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); saveRect(panel, key); }
+    document.addEventListener('mousemove', move); document.addEventListener('mouseup', up);
+    e.preventDefault();
+  }
+
+  function maximize(panel) {
+    panel.setAttribute('data-rig-pre', JSON.stringify({ left: panel.style.left, top: panel.style.top, w: panel.style.width, h: panel.style.height }));
+    panel.setAttribute('data-rig-max', '1');
+    panel.style.left = '2vw'; panel.style.top = '2vh'; panel.style.width = '96vw'; panel.style.height = '96vh';
+  }
+  function unmaximize(panel, key) {
+    var pre = null;
+    try { pre = JSON.parse(panel.getAttribute('data-rig-pre') || 'null'); } catch (e) { pre = null; }
+    panel.removeAttribute('data-rig-max');
+    if (pre) { panel.style.left = pre.left; panel.style.top = pre.top; panel.style.width = pre.w; panel.style.height = pre.h; }
+  }
+  function toggleMax(panel, key, btn) {
+    if (panel.getAttribute('data-rig-max') === '1') { unmaximize(panel, key); if (btn) btn.textContent = '⤢'; }
+    else { maximize(panel); if (btn) btn.textContent = '⤡'; }
+  }
+
+  function injectMaxBtn(handle, panel, key) {
+    if (handle.querySelector('[data-rig-max-btn]')) return;
+    var b = document.createElement('span');
+    b.setAttribute('data-rig-btn', '1'); b.setAttribute('data-rig-max-btn', '1');
+    b.title = 'maximize / restore'; b.textContent = '⤢';
+    b.style.cssText = 'cursor:pointer; opacity:.7; font-size:14px; line-height:1; margin-left:8px;';
+    b.addEventListener('click', function (e) { e.stopPropagation(); toggleMax(panel, key, b); });
+    handle.appendChild(b);
+  }
+
+  // Enhance an already-in-DOM panel.
+  function rig(panel, opts) {
+    if (!panel) return panel;
+    opts = opts || {};
+    var key = opts.key || 'default';
+    panel.style.position = 'absolute';
+    if (opts.resizable !== false) { panel.style.resize = 'both'; if (!panel.style.overflow) panel.style.overflow = 'hidden'; }
+    applyRect(panel, key, opts);
+    var handle = typeof opts.handle === 'string' ? panel.querySelector(opts.handle) : (opts.handle || panel.firstElementChild);
+    if (handle) {
+      handle.style.cursor = 'move'; handle.style.userSelect = 'none';
+      handle.addEventListener('mousedown', function (e) { startDrag(e, panel, key); });
+      if (opts.maximizable !== false) injectMaxBtn(handle, panel, key);
+    }
+    panel.addEventListener('mouseup', function () { saveRect(panel, key); });
+    // re-fit any registered onResize (e.g. a canvas/graph) when resized
+    if (opts.onResize && window.ResizeObserver) {
+      var ro = new ResizeObserver(function () { try { opts.onResize(panel); } catch (e) {} });
+      ro.observe(panel);
+    }
+    return panel;
+  }
+
+  // Collapsible section: clicking headerEl toggles bodyEl; state persists under key.
+  function section(headerEl, bodyEl, key) {
+    if (!headerEl || !bodyEl) return;
+    var collapsed = lsGet('modalrig-sec:' + key) === true;
+    var caret = document.createElement('span');
+    caret.textContent = collapsed ? '▸ ' : '▾ ';
+    headerEl.insertBefore(caret, headerEl.firstChild);
+    headerEl.style.cursor = 'pointer'; headerEl.style.userSelect = 'none';
+    bodyEl.style.display = collapsed ? 'none' : '';
+    headerEl.addEventListener('click', function () {
+      var nowOpen = bodyEl.style.display === 'none';
+      bodyEl.style.display = nowOpen ? '' : 'none';
+      caret.textContent = nowOpen ? '▾ ' : '▸ ';
+      lsSet('modalrig-sec:' + key, !nowOpen);
+    });
+  }
+
+  // Build a complete modal (backdrop + panel + header) and rig it. Returns
+  // { backdrop, panel, body, close }. Closes on backdrop click, ×, and Esc.
+  function open(o) {
+    o = o || {};
+    var key = o.key || 'modal';
+    var old = document.getElementById('modalrig-bd-' + key); if (old) old.remove();
+    var bd = document.createElement('div');
+    bd.id = 'modalrig-bd-' + key;
+    bd.style.cssText = 'position:fixed; inset:0; z-index:' + (o.z || 10000) + '; background:rgba(0,0,0,.5);';
+    var panel = document.createElement('div');
+    panel.style.cssText = 'display:flex; flex-direction:column; min-width:340px; min-height:180px; background:' + (o.bg || 'var(--card,#16181d)') + '; border:1px solid var(--border,#333); border-radius:10px; box-shadow:0 16px 56px rgba(0,0,0,.6);';
+    panel.addEventListener('click', function (e) { e.stopPropagation(); });
+    var head = document.createElement('div');
+    head.style.cssText = 'flex:0 0 auto; display:flex; justify-content:space-between; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid var(--border,#333); background:var(--bg,#0a0a0f); border-radius:10px 10px 0 0;';
+    var title = document.createElement('span');
+    title.style.cssText = 'font-weight:600; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
+    title.textContent = o.title || '';
+    var btns = document.createElement('span'); btns.style.cssText = 'flex:0 0 auto; display:flex; align-items:center;';
+    var x = document.createElement('span'); x.setAttribute('data-rig-btn', '1'); x.textContent = '×';
+    x.title = 'close (Esc)'; x.style.cssText = 'cursor:pointer; opacity:.7; font-size:18px; line-height:1; margin-left:10px;';
+    var body = document.createElement('div');
+    body.style.cssText = 'flex:1 1 auto; overflow:auto; padding:14px 18px;';
+    if (o.bodyHtml != null) body.innerHTML = o.bodyHtml; else if (o.bodyEl) body.appendChild(o.bodyEl);
+    btns.appendChild(x); head.appendChild(title); head.appendChild(btns);
+    panel.appendChild(head); panel.appendChild(body); bd.appendChild(panel); document.body.appendChild(bd);
+    function close() { bd.remove(); if (o.onClose) o.onClose(); }
+    bd.addEventListener('click', close); x.addEventListener('click', close);
+    if (!window.__modalRigEsc) { window.__modalRigEsc = true; document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { var t = document.querySelector('[id^="modalrig-bd-"]'); if (t) t.remove(); } }); }
+    rig(panel, { key: key, handle: head, resizable: o.resizable, maximizable: o.maximizable, width: o.width, height: o.height, onResize: o.onResize });
+    return { backdrop: bd, panel: panel, body: body, close: close };
+  }
+
+  window.ModalRig = { rig: rig, section: section, open: open };
+})();

← b9843c6 Add Google Calendar read + editable CRUD backend (OAuth + ev  ·  back to Dw Activation Calendar  ·  Document Google Calendar layer: README + updated server head c3c31ac →