← 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
M board.htmlA morning-digest.jsA morning-report.sh
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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 · <b>${last24.length}</b> closed in 24h · <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 →