/ src / reports / daily-progress-html-generator.js
daily-progress-html-generator.js
  1  #!/usr/bin/env node
  2  
  3  /**
  4   * Daily Progress Report HTML Generator
  5   * Generates HTML reports from daily git, database, and system activity
  6   */
  7  
  8  import { writeFileSync, existsSync, mkdirSync } from 'fs';
  9  import { join, dirname } from 'path';
 10  import { fileURLToPath } from 'url';
 11  import Logger from '../utils/logger.js';
 12  
 13  const __filename = fileURLToPath(import.meta.url);
 14  const __dirname = dirname(__filename);
 15  const projectRoot = join(__dirname, '../..');
 16  
 17  const logger = new Logger('DailyReportHTML');
 18  
 19  // Report output directory
 20  const REPORTS_DIR = join(projectRoot, 'reports');
 21  
 22  // Ensure reports directory exists
 23  if (!existsSync(REPORTS_DIR)) {
 24    mkdirSync(REPORTS_DIR, { recursive: true });
 25  }
 26  
 27  /**
 28   * Generate daily progress report HTML
 29   * @param {Object} reportData - Consolidated report data
 30   * @param {string} [outputPath] - Custom output path (optional)
 31   * @returns {Promise<string>} Path to generated HTML
 32   */
 33  export async function generateDailyReport(reportData, outputPath = null) {
 34    logger.info('Generating daily progress report HTML');
 35  
 36    // Generate HTML filename
 37    const dateString = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
 38    const filename = `daily-progress-${dateString}.html`;
 39    const filepath = outputPath || join(REPORTS_DIR, filename);
 40  
 41    // Create HTML
 42    const html = createReportHTML(reportData);
 43  
 44    // Write to file
 45    // eslint-disable-next-line security/detect-non-literal-fs-filename -- Dynamic report path
 46    writeFileSync(filepath, html, 'utf-8');
 47  
 48    logger.success(`Generated report: ${filename}`);
 49  
 50    return filepath;
 51  }
 52  
 53  /**
 54   * Create HTML document with daily progress data
 55   */
 56  function createReportHTML(data) {
 57    const today = new Date().toLocaleDateString('en-US', {
 58      year: 'numeric',
 59      month: 'long',
 60      day: 'numeric',
 61    });
 62  
 63    const generatedAt = new Date().toLocaleString('en-US', {
 64      year: 'numeric',
 65      month: 'long',
 66      day: 'numeric',
 67      hour: '2-digit',
 68      minute: '2-digit',
 69    });
 70  
 71    return `<!DOCTYPE html>
 72  <html lang="en">
 73  <head>
 74    <meta charset="UTF-8">
 75    <meta name="viewport" content="width=device-width, initial-scale=1.0">
 76    <title>Daily Progress Report - ${today}</title>
 77    <style>
 78      * {
 79        margin: 0;
 80        padding: 0;
 81        box-sizing: border-box;
 82      }
 83  
 84      body {
 85        font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
 86        line-height: 1.6;
 87        color: #333;
 88        background: #f5f5f5;
 89        padding: 20px;
 90      }
 91  
 92      .container {
 93        max-width: 900px;
 94        margin: 0 auto;
 95        background: white;
 96        padding: 40px;
 97        border-radius: 8px;
 98        box-shadow: 0 2px 4px rgba(0,0,0,0.1);
 99      }
100  
101      h1 {
102        font-size: 32px;
103        font-weight: 700;
104        color: #333;
105        margin-bottom: 8px;
106        text-align: center;
107      }
108  
109      .subtitle {
110        text-align: center;
111        color: #666;
112        font-size: 14px;
113        margin-bottom: 40px;
114      }
115  
116      h2 {
117        font-size: 20px;
118        font-weight: 600;
119        color: #333;
120        margin-top: 32px;
121        margin-bottom: 16px;
122        padding-bottom: 8px;
123        border-bottom: 2px solid #e0e0e0;
124      }
125  
126      ul {
127        list-style: none;
128        padding-left: 0;
129      }
130  
131      ul li {
132        padding: 8px 0 8px 24px;
133        position: relative;
134      }
135  
136      ul li:before {
137        content: "โ€ข";
138        position: absolute;
139        left: 8px;
140        color: #666;
141      }
142  
143      ul.sub-list {
144        margin-top: 8px;
145        margin-bottom: 8px;
146      }
147  
148      ul.sub-list li {
149        padding: 4px 0 4px 40px;
150        font-size: 14px;
151        color: #666;
152      }
153  
154      ul.sub-list li:before {
155        content: "โ—ฆ";
156        left: 24px;
157        color: #999;
158      }
159  
160      .metric-grid {
161        display: grid;
162        grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
163        gap: 20px;
164        margin: 20px 0;
165      }
166  
167      .metric-box {
168        background: #f8f8f8;
169        border: 1px solid #ddd;
170        border-radius: 6px;
171        padding: 20px;
172      }
173  
174      .metric-box h3 {
175        font-size: 16px;
176        font-weight: 600;
177        color: #333;
178        margin-bottom: 12px;
179      }
180  
181      .metric-row {
182        display: flex;
183        justify-content: space-between;
184        padding: 6px 0;
185        font-size: 14px;
186      }
187  
188      .metric-label {
189        color: #666;
190      }
191  
192      .metric-value {
193        font-weight: 600;
194        color: #000;
195      }
196  
197      .metric-value.green {
198        color: #4caf50;
199      }
200  
201      .metric-value.yellow {
202        color: #ff9800;
203      }
204  
205      .metric-value.red {
206        color: #f44336;
207      }
208  
209      .commit-list {
210        background: #f8f8f8;
211        border-left: 3px solid #2196f3;
212        padding: 16px;
213        margin: 16px 0;
214        border-radius: 4px;
215      }
216  
217      .commit {
218        padding: 8px 0;
219        font-family: 'Courier New', monospace;
220        font-size: 13px;
221      }
222  
223      .commit-hash {
224        color: #2196f3;
225        font-weight: 600;
226      }
227  
228      .commit-time {
229        color: #666;
230        font-size: 12px;
231        margin-left: 8px;
232      }
233  
234      .error-box {
235        background: #ffebee;
236        border-left: 4px solid #f44336;
237        padding: 16px;
238        margin: 16px 0;
239        border-radius: 4px;
240      }
241  
242      .error-title {
243        color: #c62828;
244        font-weight: 600;
245        margin-bottom: 8px;
246      }
247  
248      .success-box {
249        background: #e8f5e9;
250        border-left: 4px solid #4caf50;
251        padding: 16px;
252        margin: 16px 0;
253        border-radius: 4px;
254        color: #2e7d32;
255      }
256  
257      .footer {
258        text-align: center;
259        margin-top: 40px;
260        padding-top: 20px;
261        border-top: 1px solid #e0e0e0;
262        color: #999;
263        font-size: 12px;
264      }
265  
266      @media print {
267        body {
268          background: white;
269          padding: 0;
270        }
271  
272        .container {
273          box-shadow: none;
274          padding: 20px;
275        }
276      }
277    </style>
278  </head>
279  <body>
280    <div class="container">
281      <h1>๐Ÿ“Š Daily Progress Report</h1>
282      <div class="subtitle">${today} (Last 24 Hours)</div>
283  
284      <!-- Executive Summary -->
285      <h2>๐Ÿ“‹ What Shipped</h2>
286      ${generateShippedSection(data.summary?.shipped, data.gitActivity?.commits)}
287  
288      <!-- System Metrics -->
289      <h2>๐Ÿ“ˆ System Metrics</h2>
290      <div class="metric-grid">
291        ${generateDatabaseMetrics(data.dbChanges)}
292        ${generateSourceCodeMetrics(data.gitActivity)}
293        ${generateCodeQualityMetrics(data.codeQuality)}
294      </div>
295  
296      <!-- Pipeline Stage Breakdown -->
297      <h2>๐Ÿ”„ Pipeline Status</h2>
298      ${generateStatusTree(data.dbChanges?.statusTree)}
299  
300      <!-- Outreach Breakdown -->
301      <h2>๐Ÿ“ค Outreach Breakdown</h2>
302      ${generateOutreachCrossTab(data.dbChanges?.outreachCrossRows, data.dbChanges?.outreachSentToday)}
303  
304      <!-- Inbound Replies -->
305      <h2>๐Ÿ’ฌ Inbound Replies</h2>
306      ${generateConversationsCrossTab(data.dbChanges?.convCrossRows, data.dbChanges?.convChannelRows)}
307  
308      <div class="footer">
309        Report generated on ${generatedAt} | Confidential
310      </div>
311    </div>
312  </body>
313  </html>`;
314  }
315  
316  /**
317   * Generate shipped section with commits grouped by type
318   */
319  function generateShippedSection(shipped, commits) {
320    if (!shipped || shipped.length === 0) {
321      return '<p style="color: #666;">No items to report</p>';
322    }
323  
324    let html = '<ul>';
325  
326    shipped.forEach(item => {
327      html += `<li>${escapeHtml(item)}`;
328  
329      // If this item mentions features/fixes/tests, add commit details
330      const itemLower = item.toLowerCase();
331  
332      if (commits && commits.length > 0) {
333        let relevantCommits = [];
334  
335        if (itemLower.includes('feature')) {
336          relevantCommits = commits.filter(c => c.message.toLowerCase().startsWith('feat'));
337        } else if (itemLower.includes('fix') || itemLower.includes('bug')) {
338          relevantCommits = commits.filter(c => c.message.toLowerCase().startsWith('fix'));
339        } else if (itemLower.includes('test')) {
340          relevantCommits = commits.filter(c => c.message.toLowerCase().includes('test'));
341        } else if (itemLower.includes('docs') || itemLower.includes('documentation')) {
342          relevantCommits = commits.filter(c => c.message.toLowerCase().startsWith('docs'));
343        } else if (itemLower.includes('refactor')) {
344          relevantCommits = commits.filter(c => c.message.toLowerCase().startsWith('refactor'));
345        }
346  
347        if (relevantCommits.length > 0) {
348          html += '<ul class="sub-list">';
349          relevantCommits.slice(0, 10).forEach(commit => {
350            // Remove commit type prefix (feat:, fix:, etc.) for cleaner display
351            const cleanMessage = commit.message.replace(
352              /^(feat|fix|docs|test|refactor|chore|style):\s*/i,
353              ''
354            );
355            const repoTag = commit.repo
356              ? ` <span style="font-size:11px;color:#888;background:#f0f0f0;padding:1px 5px;border-radius:3px">${escapeHtml(commit.repo)}</span>`
357              : '';
358            html += `<li>${escapeHtml(cleanMessage)}${repoTag}</li>`;
359          });
360          html += '</ul>';
361        }
362      }
363  
364      html += '</li>';
365    });
366  
367    html += '</ul>';
368  
369    return html;
370  }
371  
372  /**
373   * Generate database metrics box
374   */
375  function generateDatabaseMetrics(dbChanges) {
376    if (!dbChanges) return '';
377  
378    return `
379      <div class="metric-box">
380        <h3>๐Ÿ’พ Database Overview</h3>
381        <div class="metric-row">
382          <span class="metric-label">Total Sites:</span>
383          <span class="metric-value">${dbChanges.totalSites?.toLocaleString() || '0'}</span>
384        </div>
385        <div class="metric-row">
386          <span class="metric-label">New Sites (24h):</span>
387          <span class="metric-value">${dbChanges.newSites?.toLocaleString() || '0'}</span>
388        </div>
389        <div class="metric-row">
390          <span class="metric-label">Active in Pipeline:</span>
391          <span class="metric-value">${((dbChanges.totalSites || 0) - (dbChanges.ignore || 0) - (dbChanges.failing || 0)).toLocaleString()}</span>
392        </div>
393        <div class="metric-row">
394          <span class="metric-label">Filtered/Failed:</span>
395          <span class="metric-value">${((dbChanges.ignore || 0) + (dbChanges.failing || 0)).toLocaleString()}</span>
396        </div>
397      </div>
398    `;
399  }
400  
401  /**
402   * Colors for site/outreach statuses
403   */
404  const GOOD_STATUSES = new Set([
405    'enriched',
406    'proposals_drafted',
407    'outreach_partial',
408    'outreach_sent',
409    'high_score',
410    'sent',
411    'delivered',
412    'opened',
413    'clicked',
414    'replied',
415  ]);
416  const BAD_STATUSES = new Set(['failing', 'failed', 'bounced']);
417  const GREY_STATUSES = new Set(['ignored', 'gdpr_blocked', 'gov_blocked']);
418  
419  function statusColor(status) {
420    if (GOOD_STATUSES.has(status)) return '#4caf50';
421    if (BAD_STATUSES.has(status)) return '#f44336';
422    if (GREY_STATUSES.has(status)) return '#999';
423    return '#1976d2';
424  }
425  
426  function fmtDelta(n) {
427    if (!n || n === 0) return '<span style="color:#bbb">+0</span>';
428    return `<span style="color:#4caf50">+${Number(n).toLocaleString('en-US')}</span>`;
429  }
430  
431  function fmtCumul(n) {
432    if (n === null || n === undefined) return '';
433    return `<span style="color:#999">(${Number(n).toLocaleString('en-US')})</span>`;
434  }
435  
436  /**
437   * Render a status/outreach tree as an HTML table with sub-rows.
438   * @param {Array} tree
439   * @param {{ showCumulative?: boolean }} opts
440   */
441  function renderTree(tree, { showCumulative = false } = {}) {
442    if (!tree || tree.length === 0) {
443      return '<p style="color:#666">No data available</p>';
444    }
445  
446    const cumulHeader = showCumulative
447      ? '<th style="padding:10px 12px;text-align:right;font-weight:600;color:#999">Cumul</th>'
448      : '';
449    // Check if any row has actable data
450    const hasActable = tree.some(r => r.actable !== null && r.actable !== undefined);
451    const actableHeader = hasActable
452      ? '<th style="padding:10px 12px;text-align:right;font-weight:600;color:#0288d1">Actable</th>'
453      : '';
454    const colspan = (showCumulative ? 5 : 4) + (hasActable ? 1 : 0);
455  
456    let html = `
457      <div style="overflow-x:auto">
458        <table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">
459          <thead>
460            <tr style="background:#f5f5f5;border-bottom:2px solid #ddd">
461              <th style="padding:10px 12px;text-align:left;font-weight:600">Status</th>
462              <th style="padding:10px 12px;text-align:right;font-weight:600">Total</th>
463              ${cumulHeader}
464              <th style="padding:10px 12px;text-align:right;font-weight:600">ฮ”24h</th>
465              <th style="padding:10px 12px;text-align:right;font-weight:600">ฮ”1h</th>
466              ${actableHeader}
467            </tr>
468          </thead>
469          <tbody>
470    `;
471  
472    for (const row of tree) {
473      const color = statusColor(row.status);
474      const cumulCell = showCumulative
475        ? `<td style="padding:10px 12px;text-align:right">${fmtCumul(row.cumulative)}</td>`
476        : '';
477      const actableCell = hasActable
478        ? `<td style="padding:10px 12px;text-align:right;color:#0288d1;font-weight:600">${row.actable !== null && row.actable !== undefined ? Number(row.actable).toLocaleString('en-US') : ''}</td>`
479        : '';
480      html += `
481        <tr style="border-bottom:1px solid #eee">
482          <td style="padding:10px 12px;font-weight:600;color:${color}">โ–ธ ${escapeHtml(row.status)}</td>
483          <td style="padding:10px 12px;text-align:right;font-weight:600">${Number(row.total).toLocaleString('en-US')}</td>
484          ${cumulCell}
485          <td style="padding:10px 12px;text-align:right">${fmtDelta(row.delta_24h)}</td>
486          <td style="padding:10px 12px;text-align:right">${fmtDelta(row.delta_1h)}</td>
487          ${actableCell}
488        </tr>
489      `;
490  
491      if (row.children) {
492        html += renderChildRows(row.children, { showCumulative, colspan });
493      }
494    }
495  
496    html += `</tbody></table></div>`;
497    return html;
498  }
499  
500  /**
501   * Render child rows (channels or errors)
502   */
503  function renderChildRows(children, { showCumulative = false, colspan = 4 } = {}) {
504    let html = '';
505  
506    if (children.type === 'channels') {
507      for (const r of children.rows) {
508        html += subRow(
509          `&nbsp;&nbsp;&nbsp;&nbsp;${escapeHtml(r.label)}`,
510          r.total,
511          r.delta_24h,
512          r.delta_1h,
513          '#555',
514          showCumulative
515        );
516      }
517      return html;
518    }
519  
520    if (children.type === 'errors') {
521      const { retriable, terminal, unknown } = children;
522  
523      if (retriable.length > 0) {
524        html += groupHeaderRow('Retriable', colspan);
525        for (const r of retriable) {
526          html += subRow(
527            `&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${escapeHtml(r.label)}`,
528            r.total,
529            r.delta_24h,
530            r.delta_1h,
531            '#1976d2',
532            showCumulative
533          );
534        }
535      }
536  
537      if (terminal.length > 0) {
538        html += groupHeaderRow('Terminal', colspan);
539        for (const r of terminal) {
540          html += subRow(
541            `&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${escapeHtml(r.label)}`,
542            r.total,
543            r.delta_24h,
544            r.delta_1h,
545            '#f44336',
546            showCumulative
547          );
548        }
549      }
550  
551      if (unknown.length > 0) {
552        for (const r of unknown) {
553          html += subRow(
554            `&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;${escapeHtml(r.label)}`,
555            r.total,
556            r.delta_24h,
557            r.delta_1h,
558            '#ff9800',
559            showCumulative
560          );
561        }
562      }
563    }
564  
565    return html;
566  }
567  
568  function groupHeaderRow(label, colspan = 4) {
569    return `<tr style="background:#fafafa"><td colspan="${colspan}" style="padding:4px 12px 4px 28px;font-size:12px;color:#888;font-weight:600;letter-spacing:.05em;text-transform:uppercase">${label}</td></tr>`;
570  }
571  
572  function subRow(label, total, delta24, delta1, color, showCumulative = false) {
573    const cumulCell = showCumulative ? '<td style="padding:6px 12px;text-align:right"></td>' : '';
574    return `
575      <tr style="border-bottom:1px solid #f0f0f0;background:#fdfdfd">
576        <td style="padding:6px 12px;color:${color}">${label}</td>
577        <td style="padding:6px 12px;text-align:right;color:#444">${Number(total).toLocaleString('en-US')}</td>
578        ${cumulCell}
579        <td style="padding:6px 12px;text-align:right">${fmtDelta(delta24)}</td>
580        <td style="padding:6px 12px;text-align:right">${fmtDelta(delta1)}</td>
581      </tr>
582    `;
583  }
584  
585  /**
586   * Generate pipeline status tree section
587   */
588  function generateStatusTree(statusTree) {
589    return renderTree(statusTree, { showCumulative: true });
590  }
591  
592  /**
593   * Generate outreach breakdown tree section (legacy fallback)
594   */
595  function _generateOutreachTree(outreachTree) {
596    return renderTree(outreachTree);
597  }
598  
599  /**
600   * Generate outreach cross-tab: rows=contact_method, columns=status
601   */
602  function generateOutreachCrossTab(crossRows, sentToday) {
603    if (!crossRows || crossRows.length === 0) {
604      return '<p style="color:#666">No data available</p>';
605    }
606  
607    const STATUS_ORDER = [
608      'pending',
609      'approved',
610      'scheduled',
611      'sent',
612      'delivered',
613      'opened',
614      'clicked',
615      'replied',
616      'rework',
617      'retry_later',
618      'failed',
619      'bounced',
620      'gdpr_blocked',
621      'gov_blocked',
622      'no_message_button',
623    ];
624    const STATUS_ABBR = {
625      pending: 'pending',
626      approved: 'approved',
627      scheduled: 'sched',
628      sent: 'sent',
629      delivered: 'delivrd',
630      opened: 'opened',
631      clicked: 'clicked',
632      replied: 'replied',
633      rework: 'rework',
634      retry_later: 'retry',
635      failed: 'failed',
636      bounced: 'bounced',
637      gdpr_blocked: 'gdpr',
638      gov_blocked: 'gov',
639      no_message_button: 'no-btn',
640    };
641    const STATUS_COLOR = {
642      pending: '#1976d2',
643      approved: '#1976d2',
644      scheduled: '#1976d2',
645      sent: '#388e3c',
646      delivered: '#388e3c',
647      opened: '#388e3c',
648      clicked: '#388e3c',
649      replied: '#388e3c',
650      rework: '#f57c00',
651      retry_later: '#f57c00',
652      failed: '#d32f2f',
653      bounced: '#d32f2f',
654      gdpr_blocked: '#7b1fa2',
655      gov_blocked: '#7b1fa2',
656      no_message_button: '#7b1fa2',
657    };
658    const METHOD_ORDER = ['email', 'sms', 'form', 'x', 'linkedin'];
659  
660    // Build cross-tab map
661    const crossMap = {};
662    const presentStatuses = new Set();
663    const presentMethods = new Set();
664    for (const r of crossRows) {
665      if (!crossMap[r.contact_method]) crossMap[r.contact_method] = {};
666      crossMap[r.contact_method][r.status] = r.total;
667      presentStatuses.add(r.status);
668      presentMethods.add(r.contact_method);
669    }
670    const statuses = STATUS_ORDER.filter(s => presentStatuses.has(s));
671    const methods = [
672      ...METHOD_ORDER.filter(m => presentMethods.has(m)),
673      ...[...presentMethods].filter(m => !METHOD_ORDER.includes(m)).sort(),
674    ];
675  
676    const th = s =>
677      `<th style="padding:8px 10px;text-align:right;font-weight:600;color:${STATUS_COLOR[s] || '#333'};font-size:12px">${STATUS_ABBR[s] || s}</th>`;
678    const td = (n, s) => {
679      const color = n > 0 ? STATUS_COLOR[s] || '#333' : '#ccc';
680      return `<td style="padding:8px 10px;text-align:right;color:${color}">${n > 0 ? n.toLocaleString('en-US') : 'โ€”'}</td>`;
681    };
682  
683    const methodTotals = Object.fromEntries(statuses.map(s => [s, 0]));
684    let rows = '';
685    for (const method of methods) {
686      const counts = crossMap[method] || {};
687      const rowTotal = statuses.reduce((sum, s) => sum + (counts[s] || 0), 0);
688      rows += `<tr style="border-bottom:1px solid #eee">
689        <td style="padding:8px 10px;font-weight:600">${escapeHtml(method)}</td>
690        ${statuses
691          .map(s => {
692            methodTotals[s] += counts[s] || 0;
693            return td(counts[s] || 0, s);
694          })
695          .join('')}
696        <td style="padding:8px 10px;text-align:right;font-weight:700">${rowTotal.toLocaleString('en-US')}</td>
697      </tr>`;
698    }
699    const grandTotal = statuses.reduce((sum, s) => sum + methodTotals[s], 0);
700    const totalsRow = `<tr style="border-top:2px solid #ddd;background:#f5f5f5;font-weight:700">
701      <td style="padding:8px 10px">Total</td>
702      ${statuses.map(s => `<td style="padding:8px 10px;text-align:right">${methodTotals[s].toLocaleString('en-US')}</td>`).join('')}
703      <td style="padding:8px 10px;text-align:right">${grandTotal.toLocaleString('en-US')}</td>
704    </tr>`;
705  
706    const approvedTotal = methodTotals['approved'] || 0;
707    const summaryNote =
708      sentToday !== null
709        ? `<p style="margin:8px 0 0;font-size:13px;color:#666">Sent today: <strong>+${(sentToday || 0).toLocaleString('en-US')}</strong>${approvedTotal > 0 ? `&nbsp;&nbsp;ยท&nbsp;&nbsp;Approved queue: <strong>${approvedTotal.toLocaleString('en-US')}</strong>` : ''}</p>`
710        : '';
711  
712    return `
713      <div style="overflow-x:auto">
714        <table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">
715          <thead>
716            <tr style="background:#f5f5f5;border-bottom:2px solid #ddd">
717              <th style="padding:8px 10px;text-align:left;font-weight:600">Channel</th>
718              ${statuses.map(th).join('')}
719              <th style="padding:8px 10px;text-align:right;font-weight:600">Total</th>
720            </tr>
721          </thead>
722          <tbody>${rows}${totalsRow}</tbody>
723        </table>
724      </div>
725      ${summaryNote}
726    `;
727  }
728  
729  /**
730   * Generate inbound conversations cross-tab: rows=intent, columns=sentiment
731   */
732  function generateConversationsCrossTab(crossRows, channelRows) {
733    if (!crossRows || crossRows.length === 0) {
734      return '<p style="color:#666">No inbound replies yet</p>';
735    }
736  
737    const SENTIMENT_ORDER = ['positive', 'neutral', 'negative', 'objection'];
738    const SENTIMENT_COLOR = {
739      positive: '#388e3c',
740      neutral: '#1976d2',
741      negative: '#d32f2f',
742      objection: '#f57c00',
743    };
744  
745    // Build cross-tab map: intent -> sentiment -> count
746    const intentMap = {};
747    const presentSentiments = new Set();
748    for (const r of crossRows) {
749      if (!intentMap[r.intent]) intentMap[r.intent] = {};
750      intentMap[r.intent][r.sentiment] = r.total;
751      presentSentiments.add(r.sentiment);
752    }
753    const sentiments = SENTIMENT_ORDER.filter(s => presentSentiments.has(s));
754    const intents = Object.keys(intentMap).sort((a, b) => {
755      const totA = sentiments.reduce((s, sent) => s + (intentMap[a][sent] || 0), 0);
756      const totB = sentiments.reduce((s, sent) => s + (intentMap[b][sent] || 0), 0);
757      return totB - totA;
758    });
759  
760    const th = s =>
761      `<th style="padding:8px 10px;text-align:right;font-weight:600;color:${SENTIMENT_COLOR[s] || '#333'};font-size:12px">${s}</th>`;
762    const td = (n, s) => {
763      const color = n > 0 ? SENTIMENT_COLOR[s] || '#333' : '#ccc';
764      return `<td style="padding:8px 10px;text-align:right;color:${color}">${n > 0 ? n.toLocaleString('en-US') : 'โ€”'}</td>`;
765    };
766  
767    const sentTotals = Object.fromEntries(sentiments.map(s => [s, 0]));
768    let rows = '';
769    for (const intent of intents) {
770      const counts = intentMap[intent];
771      const rowTotal = sentiments.reduce((sum, s) => sum + (counts[s] || 0), 0);
772      rows += `<tr style="border-bottom:1px solid #eee">
773        <td style="padding:8px 10px;font-weight:600">${escapeHtml(intent)}</td>
774        ${sentiments
775          .map(s => {
776            sentTotals[s] += counts[s] || 0;
777            return td(counts[s] || 0, s);
778          })
779          .join('')}
780        <td style="padding:8px 10px;text-align:right;font-weight:700">${rowTotal.toLocaleString('en-US')}</td>
781      </tr>`;
782    }
783    const grandTotal = sentiments.reduce((sum, s) => sum + sentTotals[s], 0);
784    const totalsRow = `<tr style="border-top:2px solid #ddd;background:#f5f5f5;font-weight:700">
785      <td style="padding:8px 10px">Total</td>
786      ${sentiments.map(s => `<td style="padding:8px 10px;text-align:right">${sentTotals[s].toLocaleString('en-US')}</td>`).join('')}
787      <td style="padding:8px 10px;text-align:right">${grandTotal.toLocaleString('en-US')}</td>
788    </tr>`;
789  
790    const channelSummary = channelRows?.length
791      ? `<p style="margin:8px 0 0;font-size:13px;color:#666">Channels: ${channelRows.map(r => `<strong>${escapeHtml(r.channel)}</strong> ${r.total}`).join(' ยท ')}</p>`
792      : '';
793  
794    return `
795      <div style="overflow-x:auto">
796        <table style="width:100%;border-collapse:collapse;margin:16px 0;font-size:14px">
797          <thead>
798            <tr style="background:#f5f5f5;border-bottom:2px solid #ddd">
799              <th style="padding:8px 10px;text-align:left;font-weight:600">Intent</th>
800              ${sentiments.map(th).join('')}
801              <th style="padding:8px 10px;text-align:right;font-weight:600">Total</th>
802            </tr>
803          </thead>
804          <tbody>${rows}${totalsRow}</tbody>
805        </table>
806      </div>
807      ${channelSummary}
808    `;
809  }
810  
811  /**
812   * Generate source code changes metrics box
813   */
814  function generateSourceCodeMetrics(gitActivity) {
815    if (!gitActivity?.commits?.length) return '';
816  
817    // Per-repo commit counts
818    const repoCounts = {};
819    for (const c of gitActivity.commits) {
820      const r = c.repo || '333Method';
821      repoCounts[r] = (repoCounts[r] || 0) + 1;
822    }
823    const repoRows = Object.entries(repoCounts)
824      .sort((a, b) => b[1] - a[1])
825      .map(
826        ([repo, count]) => `
827        <div class="metric-row">
828          <span class="metric-label" style="padding-left:12px;color:#666">${escapeHtml(repo)}:</span>
829          <span class="metric-value">${count}</span>
830        </div>`
831      )
832      .join('');
833  
834    return `
835      <div class="metric-box">
836        <h3>๐Ÿ’ป Source Code Changes</h3>
837        <div class="metric-row">
838          <span class="metric-label">Commits (all repos):</span>
839          <span class="metric-value">${gitActivity.commits.length}</span>
840        </div>
841        ${repoRows}
842        <div class="metric-row">
843          <span class="metric-label">Files Changed:</span>
844          <span class="metric-value">${gitActivity.filesChanged || 0}</span>
845        </div>
846        <div class="metric-row">
847          <span class="metric-label">Lines Added:</span>
848          <span class="metric-value green">+${gitActivity.linesAdded?.toLocaleString() || 0}</span>
849        </div>
850        <div class="metric-row">
851          <span class="metric-label">Lines Removed:</span>
852          <span class="metric-value red">-${gitActivity.linesRemoved?.toLocaleString() || 0}</span>
853        </div>
854      </div>
855    `;
856  }
857  
858  /**
859   * Generate code quality metrics box
860   */
861  function generateCodeQualityMetrics(codeQuality) {
862    if (!codeQuality) return '';
863  
864    const { coverage, coverageChange } = codeQuality;
865  
866    const coverageColor = coverage >= 80 ? 'green' : coverage >= 70 ? 'yellow' : 'red';
867    const changeColor = coverageChange > 0 ? 'green' : coverageChange < 0 ? 'red' : '';
868  
869    return `
870      <div class="metric-box">
871        <h3>โœ… Code Quality</h3>
872        <div class="metric-row">
873          <span class="metric-label">Test Coverage:</span>
874          <span class="metric-value ${coverageColor}">${coverage ? `${coverage}%` : 'N/A'}</span>
875        </div>
876        <div class="metric-row">
877          <span class="metric-label">Coverage Change:</span>
878          <span class="metric-value ${changeColor}">
879            ${coverageChange !== undefined ? `${coverageChange > 0 ? '+' : ''}${coverageChange}%` : 'N/A'}
880          </span>
881        </div>
882        <div class="metric-row">
883          <span class="metric-label">Failing Tests:</span>
884          <span class="metric-value">${codeQuality.failingTests || 0}</span>
885        </div>
886        <div class="metric-row">
887          <span class="metric-label">New TODOs:</span>
888          <span class="metric-value">${codeQuality.newTodos || 0}</span>
889        </div>
890      </div>
891    `;
892  }
893  
894  /**
895   * Escape HTML special characters
896   */
897  function escapeHtml(text) {
898    const map = {
899      '&': '&amp;',
900      '<': '&lt;',
901      '>': '&gt;',
902      '"': '&quot;',
903      "'": '&#039;',
904    };
905  
906    return String(text).replace(/[&<>"']/g, m => map[m]);
907  }
908  
909  export default {
910    generateDailyReport,
911  };