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 ` ${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 ` ${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 ` ${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 ` ${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 ? ` ยท 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 '&': '&', 900 '<': '<', 901 '>': '>', 902 '"': '"', 903 "'": ''', 904 }; 905 906 return String(text).replace(/[&<>"']/g, m => map[m]); 907 } 908 909 export default { 910 generateDailyReport, 911 };