[object Object]

← back to Ticket System

Add 6:15am ticket morning-report: digest email (closed overnight/24h + open w/ board deep-links) + resumeit; board.html #q= hash deep-link

37b3176e117c435c70273de15bf32902e92c1f7e · 2026-08-11 06:29:56 -0700 · steve@designerwallcoverings.com

Files touched

Diff

commit 37b3176e117c435c70273de15bf32902e92c1f7e
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Tue Aug 11 06:29:56 2026 -0700

    Add 6:15am ticket morning-report: digest email (closed overnight/24h + open w/ board deep-links) + resumeit; board.html #q= hash deep-link
---
 board.html        |   8 +++-
 morning-digest.js | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
 morning-report.sh |  44 +++++++++++++++++++++
 3 files changed, 163 insertions(+), 1 deletion(-)

diff --git a/board.html b/board.html
index aa36d193..ce902a0a 100644
--- a/board.html
+++ b/board.html
@@ -204,8 +204,14 @@ async function load(){
   try{
     const base=location.origin; // never carries userinfo — fixes "URL includes credentials" fetch error when opened via admin:pass@host
     const [t,m]=await Promise.all([fetch(base+'/api/tickets').then(r=>r.json()),fetch(base+'/api/messages').then(r=>r.json())]);
-    DATA=t; LASTMSG=m; render(); dmPanel(m);
+    DATA=t; LASTMSG=m;
+    // Deep-link support: a URL like /#q=TK-10454 (from the morning digest email)
+    // pre-filters the board to that ticket so you land right on it.
+    const hq=decodeURIComponent((location.hash.match(/[#&]q=([^&]+)/)||[])[1]||'');
+    if(hq && !q){ q=hq; $('#q').value=hq; }
+    render(); dmPanel(m);
   }catch(e){$('#tbody').innerHTML=`<tr><td>load failed: ${esc(e.message)}</td></tr>`;}
 }
+window.addEventListener('hashchange',()=>{const hq=decodeURIComponent((location.hash.match(/[#&]q=([^&]+)/)||[])[1]||'');setQ(hq);});
 renderColMenu(); renderPillMenu(); renderDetMenu(); $('#dmBtn').classList.toggle('off',!DM_ON); load(); setInterval(load,30000);
 </script></body></html>
diff --git a/morning-digest.js b/morning-digest.js
new file mode 100755
index 00000000..40b4d554
--- /dev/null
+++ b/morning-digest.js
@@ -0,0 +1,112 @@
+#!/opt/homebrew/bin/node
+// morning-digest.js — builds the 6:15am ticket digest email as JSON {subject, html}.
+// Reads the shared append-only event log directly so it can recover each ticket's
+// most-recent "done" transition timestamp (lib.js only exposes final state, not
+// WHEN a ticket closed). A ticket counts as "closed in window" only if its CURRENT
+// status is done AND that done-transition landed inside the window — so a ticket
+// that was closed then reopened is correctly excluded.
+//
+// Windows (all local time, env-overridable):
+//   overnight  = since OVERNIGHT_FROM_HOUR:00 the previous evening (default 18:00 / 6pm)
+//   last 24h   = now - 24h
+// Output: JSON on stdout → the wrapper sends it via george-send.sh.
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const EVENTS = path.join(os.homedir(), '.claude', 'tickets', 'events.jsonl');
+const BOARD_URL = (process.env.BOARD_URL || 'http://127.0.0.1:9794').replace(/\/$/, '');
+const FROM_HOUR = parseInt(process.env.OVERNIGHT_FROM_HOUR || '18', 10);
+
+const now = new Date();
+// overnight anchor: today at FROM_HOUR:00; if that's still in the future (i.e. we're
+// running in the morning), step back a day → "since 6pm last night".
+const overnightStart = new Date(now);
+overnightStart.setHours(FROM_HOUR, 0, 0, 0);
+if (overnightStart > now) overnightStart.setDate(overnightStart.getDate() - 1);
+const day24Start = new Date(now.getTime() - 24 * 3600 * 1000);
+
+const STATUSES = ['open', 'doing', 'blocked', 'done'];
+const events = fs.readFileSync(EVENTS, 'utf8').split('\n').filter(Boolean)
+  .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+
+// Fold events → ticket state, additionally tracking lastDoneTs.
+const map = new Map();
+for (const ev of events) {
+  if (ev.type === 'create') {
+    map.set(ev.id, { id: ev.id, title: ev.title, project: ev.project || '',
+      assignee: ev.agent || '', status: 'open', created_at: ev.ts, updated_at: ev.ts, lastDoneTs: null });
+  } else {
+    const t = map.get(ev.id); if (!t) continue; t.updated_at = ev.ts;
+    if (ev.type === 'assign') t.assignee = ev.agent || t.assignee;
+    else if (ev.type === 'status' && STATUSES.includes(ev.status)) {
+      t.status = ev.status;
+      if (ev.status === 'done') t.lastDoneTs = ev.ts; // most-recent close
+    }
+  }
+}
+const all = [...map.values()];
+
+const closedIn = start => all
+  .filter(t => t.status === 'done' && t.lastDoneTs && new Date(t.lastDoneTs) >= start)
+  .sort((a, b) => a.lastDoneTs < b.lastDoneTs ? 1 : -1);
+const overnight = closedIn(overnightStart);
+const last24 = closedIn(day24Start);
+
+const openRank = { doing: 0, blocked: 1, open: 2 };
+const openTix = all.filter(t => t.status !== 'done')
+  .sort((a, b) => (openRank[a.status] - openRank[b.status]) || (a.updated_at < b.updated_at ? 1 : -1));
+
+// ── rendering helpers ──
+const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+const shortId = id => id.replace(/^(TK-\d+).*/, '$1');
+const fmtTime = ts => ts ? new Date(ts).toLocaleString(undefined,
+  { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '';
+const link = id => `${BOARD_URL}/#q=${encodeURIComponent(shortId(id))}`;
+const badge = { doing: '#2f7d4f', blocked: '#b23b3b', open: '#5b6270', done: '#3a6ea5' };
+const statusChip = s => `<span style="display:inline-block;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:#fff;background:${badge[s] || '#666'};border-radius:10px;padding:1px 7px">${s}</span>`;
+
+function closedRow(t) {
+  return `<tr>
+    <td style="padding:6px 10px 6px 0;white-space:nowrap;vertical-align:top">
+      <a href="${link(t.id)}" style="color:#3a6ea5;text-decoration:none;font-weight:600;font-family:ui-monospace,Menlo,monospace;font-size:12px">${esc(shortId(t.id))}</a></td>
+    <td style="padding:6px 0;vertical-align:top">${esc(t.title)}
+      <div style="color:#8a93a5;font-size:11px;margin-top:2px">${esc(t.assignee || 'unassigned')}${t.project ? ' · ' + esc(t.project) : ''} · 🏁 ${esc(fmtTime(t.lastDoneTs))}</div></td>
+  </tr>`;
+}
+function openRow(t) {
+  return `<tr>
+    <td style="padding:6px 10px 6px 0;white-space:nowrap;vertical-align:top">
+      <a href="${link(t.id)}" style="color:#3a6ea5;text-decoration:none;font-weight:600;font-family:ui-monospace,Menlo,monospace;font-size:12px">${esc(shortId(t.id))}</a></td>
+    <td style="padding:6px 8px 6px 0;vertical-align:top">${statusChip(t.status)}</td>
+    <td style="padding:6px 0;vertical-align:top">${esc(t.title)}
+      <div style="color:#8a93a5;font-size:11px;margin-top:2px">${esc(t.assignee || 'unassigned')}${t.project ? ' · ' + esc(t.project) : ''} · 🕓 ${esc(fmtTime(t.created_at))}</div></td>
+  </tr>`;
+}
+const section = (title, sub, rowsHtml, empty) => `
+  <h2 style="font-size:14px;letter-spacing:.04em;color:#111;margin:22px 0 2px">${esc(title)}
+    <span style="color:#8a93a5;font-weight:400;font-size:12px">${esc(sub)}</span></h2>
+  ${rowsHtml ? `<table style="border-collapse:collapse;width:100%;font-size:13px;color:#1a1a1a">${rowsHtml}</table>`
+    : `<div style="color:#8a93a5;font-size:13px;padding:4px 0">${esc(empty)}</div>`}`;
+
+const dateLabel = now.toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' });
+const html = `<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;max-width:760px;margin:0 auto;color:#1a1a1a">
+  <div style="border-bottom:2px solid #111;padding-bottom:8px;margin-bottom:4px">
+    <div style="font-size:18px;font-weight:700">🎟️ Ticket Morning Report</div>
+    <div style="color:#8a93a5;font-size:13px">${esc(dateLabel)} · board: <a href="${BOARD_URL}/" style="color:#3a6ea5">${esc(BOARD_URL)}</a></div>
+  </div>
+  <div style="display:inline-block;font-size:13px;color:#444;background:#f4f6f9;border-radius:8px;padding:6px 12px;margin:8px 0">
+    <b>${overnight.length}</b> closed overnight &nbsp;·&nbsp; <b>${last24.length}</b> closed in 24h &nbsp;·&nbsp; <b>${openTix.length}</b> open</div>
+
+  ${section('✅ Closed overnight', `since ${fmtTime(overnightStart)}`, overnight.map(closedRow).join(''), 'Nothing closed overnight.')}
+  ${section('✅ Closed in the last 24 hours', `since ${fmtTime(day24Start)} · includes the overnight set above`, last24.map(closedRow).join(''), 'Nothing closed in the last 24 hours.')}
+  ${section('📋 Open tickets — click a ticket to open it on the board', `${openTix.length} open (doing → blocked → open) · links pre-filter the board so you can make your selections`, openTix.map(openRow).join(''), 'No open tickets.')}
+
+  <p style="color:#aab; font-size:11px; margin-top:26px; border-top:1px solid #eee; padding-top:8px">
+    Ticket links open the board filtered to that ticket (works when opened on the Mac Studio, where the board runs).
+    Generated ${esc(now.toLocaleString())}.</p>
+</div>`;
+
+const subject = `🎟️ Tickets: ${overnight.length} closed overnight · ${openTix.length} open — ${now.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}`;
+process.stdout.write(JSON.stringify({ subject, html,
+  counts: { overnight: overnight.length, last24: last24.length, open: openTix.length } }));
diff --git a/morning-report.sh b/morning-report.sh
new file mode 100755
index 00000000..905f15a4
--- /dev/null
+++ b/morning-report.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# morning-report.sh — Steve's 6:15am routine (TK-10454):
+#   1. email the ticket digest (closed overnight + closed 24h + open tickets w/ board links)
+#   2. /resumeit — nudge every open iTerm2 Claude session back into motion
+# Runs headless via launchd (com.steve.ticket-morning-report). Log: morning-report.log
+set -uo pipefail
+cd "$(dirname "$0")" || exit 1
+
+NODE=/opt/homebrew/bin/node
+LOG="$(dirname "$0")/morning-report.log"
+TO="${REPORT_TO:-steve@designerwallcoverings.com}"
+log(){ echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG"; }
+
+log "=== morning report start ==="
+
+# 1) Build + send the digest
+JSON="$("$NODE" morning-digest.js 2>>"$LOG")"
+if [ -z "$JSON" ]; then log "ERROR: digest builder produced no output"; else
+  SUBJECT="$(printf '%s' "$JSON" | jq -r .subject)"
+  BODY="$(printf '%s' "$JSON" | jq -r .html)"
+  COUNTS="$(printf '%s' "$JSON" | jq -rc .counts)"
+  if [ -f "$HOME/.claude/skills/_shared/george-send.sh" ]; then
+    . "$HOME/.claude/skills/_shared/george-send.sh"
+    RESP="$(george_send steve-office "$TO" "$SUBJECT" "$BODY")"
+    printf '%s' "$RESP" | grep -q '"success":true' \
+      && log "email sent → $TO  counts=$COUNTS" \
+      || log "EMAIL FAILED → $TO  resp=$RESP"
+  else
+    log "ERROR: george-send.sh missing"
+  fi
+fi
+
+# 2) Resume all iTerm2 sessions (backgrounded — 10s pacing between sessions;
+#    don't block the job). Only meaningful when a GUI session is present.
+if pgrep -x iTerm2 >/dev/null 2>&1 || pgrep -f "iTerm.app" >/dev/null 2>&1; then
+  # No caller session in a scheduled run → export an empty ITERM_SESSION_ID so
+  # resumeit.sh (set -u) doesn't abort, and so it nudges ALL sessions (nothing to skip).
+  ITERM_SESSION_ID='' nohup zsh "$HOME/.claude/skills/resumeit/resumeit.sh" >>"$LOG" 2>&1 &
+  log "resumeit dispatched (backgrounded)"
+else
+  log "resumeit skipped (iTerm2 not running)"
+fi
+
+log "=== morning report done ==="

← 9e7e35fe chore: 5x report — board verified clean (contrarian-gated),  ·  back to Ticket System  ·  auto-data-snapshot: 2026-08-11T07:35:48 (1 data files) — tk1 ba94848a →