← back to Travis

public/index.html

700 lines

<!DOCTYPE html>
<html lang="en">
<head>
    <link rel="icon" type="image/svg+xml" href="/favicon.svg">

  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Stereo Report</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
  <style>
    @import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');

    * { margin: 0; padding: 0; box-sizing: border-box; }

    body {
      font-family: 'Inter', -apple-system, sans-serif;
      background: #fafafa;
      color: #1a1a1a;
      min-height: 100vh;
    }

    .controls {
      position: fixed;
      top: 16px;
      right: 16px;
      z-index: 100;
      display: flex;
      gap: 8px;
    }

    .controls button {
      padding: 8px 16px;
      border: 1px solid #d1d5db;
      border-radius: 6px;
      background: white;
      font-size: 12px;
      font-weight: 500;
      cursor: pointer;
      font-family: 'Inter', sans-serif;
      transition: all 0.15s;
    }

    .controls button:hover {
      background: #111;
      color: white;
      border-color: #111;
    }

    .controls button.pdf-btn {
      background: #111;
      color: white;
      border-color: #111;
    }

    .controls button.pdf-btn:hover {
      background: #333;
    }

    /* ── Report Page ── */
    .report-page {
      width: 8.5in;
      min-height: 11in;
      margin: 20px auto;
      background: white;
      padding: 0.6in 0.7in;
      box-shadow: 0 1px 3px rgba(0,0,0,0.08);
      position: relative;
    }

    @media print {
      .controls { display: none; }
      body { background: white; }
      .report-page { box-shadow: none; margin: 0; padding: 0.5in 0.6in; }
    }

    /* ── Logo Area ── */
    .logo-area {
      text-align: center;
      margin-bottom: 12px;
      position: relative;
    }

    .logo-area img {
      max-height: 60px;
      max-width: 240px;
      object-fit: contain;
    }

    .logo-upload {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      padding: 10px 20px;
      border: 1.5px dashed #d1d5db;
      border-radius: 8px;
      cursor: pointer;
      color: #9ca3af;
      font-size: 11px;
      font-weight: 500;
      letter-spacing: 0.3px;
      transition: all 0.15s;
    }

    .logo-upload:hover {
      border-color: #9ca3af;
      color: #6b7280;
    }

    .logo-upload svg {
      width: 16px;
      height: 16px;
    }

    #logo-input { display: none; }

    /* ── Header ── */
    .report-header {
      text-align: center;
      margin-bottom: 28px;
      padding-bottom: 16px;
      border-bottom: 1.5px solid #e5e7eb;
    }

    .report-title {
      font-size: 26px;
      font-weight: 700;
      letter-spacing: -0.5px;
      color: #111;
      margin-bottom: 4px;
    }

    .report-date {
      font-size: 12px;
      color: #9ca3af;
      font-weight: 400;
      letter-spacing: 0.5px;
    }

    /* ── Chart Section ── */
    .chart-section {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 40px;
      margin-bottom: 32px;
    }

    .chart-container {
      width: 220px;
      height: 220px;
      position: relative;
    }

    .chart-legend {
      display: flex;
      flex-direction: column;
      gap: 10px;
    }

    .legend-item {
      display: flex;
      align-items: center;
      gap: 10px;
    }

    .legend-dot {
      width: 12px;
      height: 12px;
      border-radius: 3px;
      flex-shrink: 0;
    }

    .legend-label {
      font-size: 12px;
      color: #6b7280;
      font-weight: 400;
      min-width: 90px;
    }

    .legend-value {
      font-size: 14px;
      font-weight: 600;
      color: #111;
      font-variant-numeric: tabular-nums;
    }

    .legend-pct {
      font-size: 11px;
      color: #9ca3af;
      font-weight: 400;
      margin-left: 4px;
    }

    /* ── Stats Grid ── */
    .stats-grid {
      display: grid;
      grid-template-columns: repeat(4, 1fr);
      gap: 16px;
      margin-bottom: 28px;
    }

    .stat-card {
      text-align: center;
      padding: 18px 12px;
      border: 1px solid #f0f0f0;
      border-radius: 10px;
      background: #fafafa;
    }

    .stat-value {
      font-size: 28px;
      font-weight: 700;
      color: #111;
      line-height: 1;
      margin-bottom: 6px;
      font-variant-numeric: tabular-nums;
    }

    .stat-value.green { color: #16a34a; }
    .stat-value.amber { color: #d97706; }
    .stat-value.red { color: #dc2626; }
    .stat-value.blue { color: #2563eb; }

    .stat-label {
      font-size: 10px;
      font-weight: 500;
      color: #9ca3af;
      text-transform: uppercase;
      letter-spacing: 0.8px;
    }

    /* ── Breakdown Table ── */
    .breakdown {
      margin-bottom: 24px;
    }

    .breakdown-title {
      font-size: 11px;
      font-weight: 600;
      color: #9ca3af;
      text-transform: uppercase;
      letter-spacing: 1px;
      margin-bottom: 12px;
    }

    .breakdown-table {
      width: 100%;
      border-collapse: collapse;
    }

    .breakdown-table th {
      text-align: left;
      font-size: 10px;
      font-weight: 600;
      color: #9ca3af;
      text-transform: uppercase;
      letter-spacing: 0.5px;
      padding: 8px 12px;
      border-bottom: 1.5px solid #e5e7eb;
    }

    .breakdown-table th:not(:first-child) {
      text-align: right;
    }

    .breakdown-table td {
      padding: 10px 12px;
      font-size: 13px;
      border-bottom: 1px solid #f3f4f6;
    }

    .breakdown-table td:not(:first-child) {
      text-align: right;
      font-variant-numeric: tabular-nums;
      font-weight: 500;
    }

    .breakdown-table tr:last-child td {
      border-bottom: none;
    }

    .breakdown-table .total-row td {
      font-weight: 700;
      border-top: 1.5px solid #e5e7eb;
      border-bottom: none;
      padding-top: 12px;
    }

    .pct-bar {
      display: inline-flex;
      align-items: center;
      gap: 6px;
    }

    .pct-bar-fill {
      height: 6px;
      border-radius: 3px;
      display: inline-block;
      min-width: 2px;
    }

    .pct-text {
      font-size: 11px;
      color: #9ca3af;
    }

    /* ── Footer ── */
    .report-footer {
      position: absolute;
      bottom: 0.5in;
      left: 0.7in;
      right: 0.7in;
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding-top: 12px;
      border-top: 1px solid #f0f0f0;
      font-size: 9px;
      color: #d1d5db;
    }

    /* ── Turnover bar ── */
    .turnover-bar-container {
      margin-bottom: 28px;
    }

    .turnover-bar-header {
      display: flex;
      justify-content: space-between;
      margin-bottom: 8px;
    }

    .turnover-bar-label {
      font-size: 11px;
      font-weight: 500;
      color: #6b7280;
    }

    .turnover-bar-pct {
      font-size: 13px;
      font-weight: 700;
      color: #16a34a;
    }

    .turnover-bar {
      height: 10px;
      border-radius: 5px;
      background: #f3f4f6;
      overflow: hidden;
      display: flex;
    }

    .turnover-bar-segment {
      height: 100%;
      transition: width 0.6s ease;
    }
  </style>
<!-- Meta Pixel — INERT until set-fb-pixel.sh is run (does NOT load fbevents.js or hit Meta until flipped) -->
<script>
(function(){
  var PIXEL_ID = '{{FACEBOOK_PIXEL_ID}}';
  // Inert until flipped — the brace check prevents BOTH the loader IIFE
  // AND the init call from running, so fbevents.js is never fetched.
  if (PIXEL_ID.indexOf('{{') !== -1) return;
  !function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
  n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
  n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
  t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,
  document,'script','https://connect.facebook.net/en_US/fbevents.js');
  fbq('init', PIXEL_ID);
  fbq('track', 'PageView');
})();
</script>
<!-- noscript fallback is added by set-fb-pixel.sh on flip (omitted here to avoid unflipped img phone-home) -->
<!-- End Meta Pixel -->
</head>
<body>
  <!-- Controls (hidden in print/PDF) -->
  <div class="controls" id="controls">
    <button onclick="window.print()">Print</button>
    <button class="pdf-btn" onclick="downloadPDF()">Download PDF</button>
  </div>

  <!-- Report -->
  <div class="report-page" id="report">
    <!-- Logo -->
    <div class="logo-area" id="logo-area">
      <label class="logo-upload" id="logo-label">
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5A2.25 2.25 0 0022.5 18.75V5.25A2.25 2.25 0 0020.25 3H3.75A2.25 2.25 0 001.5 5.25v13.5A2.25 2.25 0 003.75 21z" /></svg>
        Click to upload project logo (.jpeg)
        <input type="file" id="logo-input" accept=".jpg,.jpeg,.png,.svg,.webp">
      </label>
    </div>

    <!-- Header -->
    <div class="report-header">
      <div class="report-title">Stereo Report</div>
      <div class="report-date" id="report-date"></div>
    </div>

    <!-- Pie Chart + Legend -->
    <div class="chart-section">
      <div class="chart-container">
        <canvas id="pieChart"></canvas>
      </div>
      <div class="chart-legend" id="chart-legend"></div>
    </div>

    <!-- Turnover Progress Bar -->
    <div class="turnover-bar-container">
      <div class="turnover-bar-header">
        <span class="turnover-bar-label">Overall Turnover Progress</span>
        <span class="turnover-bar-pct" id="turnover-pct"></span>
      </div>
      <div class="turnover-bar" id="turnover-bar"></div>
    </div>

    <!-- Stats Cards -->
    <div class="stats-grid" id="stats-grid"></div>

    <!-- Breakdown Table -->
    <div class="breakdown">
      <div class="breakdown-title">Shot Breakdown</div>
      <table class="breakdown-table">
        <thead>
          <tr>
            <th>Category</th>
            <th>Shots</th>
            <th>Frames</th>
            <th>Duration</th>
            <th>% of Total</th>
          </tr>
        </thead>
        <tbody id="breakdown-body"></tbody>
      </table>
    </div>

    <!-- Turnover Detail Table -->
    <div class="breakdown">
      <div class="breakdown-title">Turnover Status (Active Shots Only)</div>
      <table class="breakdown-table">
        <thead>
          <tr>
            <th>Status</th>
            <th>Shots</th>
            <th>% of Active</th>
            <th>Progress</th>
          </tr>
        </thead>
        <tbody id="turnover-body"></tbody>
      </table>
    </div>

    <!-- Footer -->
    <div class="report-footer">
      <span>Stereo Conversion Report</span>
      <span id="footer-date"></span>
    </div>
  </div>

  <script>
    const COLORS = {
      turnedOver: '#16a34a',
      nto: '#ef4444',
      hold: '#f59e0b',
      vfx: '#2563eb',
      nvx: '#8b5cf6',
    };

    // Format date
    function fmtDate(d) {
      return new Date(d).toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
    }

    function commas(n) { return n.toLocaleString(); }
    function framesToTC(f) {
      const sec = Math.floor(f / 24);
      const m = Math.floor(sec / 60);
      const h = Math.floor(m / 60);
      if (h > 0) return `${h}h ${m % 60}m ${sec % 60}s`;
      return `${m}m ${sec % 60}s`;
    }

    // Logo upload
    document.getElementById('logo-input').addEventListener('change', function(e) {
      const file = e.target.files[0];
      if (!file) return;
      const reader = new FileReader();
      reader.onload = function(ev) {
        const area = document.getElementById('logo-area');
        area.innerHTML = `<img src="${ev.target.result}" alt="Project Logo" onclick="document.getElementById('logo-re').click()"><input type="file" id="logo-re" accept=".jpg,.jpeg,.png,.svg,.webp" style="display:none" onchange="reupload(event)">`;
      };
      reader.readAsDataURL(file);
    });

    function reupload(e) {
      const file = e.target.files[0];
      if (!file) return;
      const reader = new FileReader();
      reader.onload = function(ev) {
        document.querySelector('#logo-area img').src = ev.target.result;
      };
      reader.readAsDataURL(file);
    }

    // Load data
    async function loadReport() {
      const data = await fetch('/api/report').then(r => r.json());

      // Date
      const dateStr = fmtDate(data.date);
      document.getElementById('report-date').textContent = dateStr;
      document.getElementById('footer-date').textContent = dateStr;

      // Pie chart
      const pieCtx = document.getElementById('pieChart').getContext('2d');
      const pieLabels = [];
      const pieValues = [];
      const pieColors = [];

      if (data.turnover.turnedOver > 0) {
        pieLabels.push('Turned Over');
        pieValues.push(data.turnover.turnedOver);
        pieColors.push(COLORS.turnedOver);
      }
      if (data.turnover.nto > 0) {
        pieLabels.push('NTO');
        pieValues.push(data.turnover.nto);
        pieColors.push(COLORS.nto);
      }
      if (data.turnover.hold > 0) {
        pieLabels.push('HOLD');
        pieValues.push(data.turnover.hold);
        pieColors.push(COLORS.hold);
      }
      if (data.turnover.other > 0) {
        pieLabels.push('Other');
        pieValues.push(data.turnover.other);
        pieColors.push('#94a3b8');
      }

      new Chart(pieCtx, {
        type: 'doughnut',
        data: {
          labels: pieLabels,
          datasets: [{
            data: pieValues,
            backgroundColor: pieColors,
            borderWidth: 0,
            hoverOffset: 4,
          }]
        },
        options: {
          responsive: true,
          maintainAspectRatio: true,
          cutout: '62%',
          plugins: {
            legend: { display: false },
            tooltip: {
              callbacks: {
                label: ctx => ` ${ctx.label}: ${commas(ctx.raw)} shots (${((ctx.raw / data.activeShots) * 100).toFixed(1)}%)`
              }
            }
          }
        }
      });

      // Legend
      const legendEl = document.getElementById('chart-legend');
      const legendItems = [
        { label: 'Turned Over', value: data.turnover.turnedOver, color: COLORS.turnedOver, pct: data.percentTurnedOver },
        { label: 'NTO', value: data.turnover.nto, color: COLORS.nto, pct: data.percentNTO },
        { label: 'HOLD', value: data.turnover.hold, color: COLORS.hold, pct: data.percentHold },
      ];
      legendEl.innerHTML = legendItems.map(l => `
        <div class="legend-item">
          <div class="legend-dot" style="background:${l.color}"></div>
          <span class="legend-label">${l.label}</span>
          <span class="legend-value">${commas(l.value)}<span class="legend-pct">(${l.pct}%)</span></span>
        </div>
      `).join('');

      // Turnover progress bar
      document.getElementById('turnover-pct').textContent = `${data.percentTurnedOver}%`;
      const bar = document.getElementById('turnover-bar');
      bar.innerHTML = `
        <div class="turnover-bar-segment" style="width:${data.percentTurnedOver}%;background:${COLORS.turnedOver}"></div>
        <div class="turnover-bar-segment" style="width:${data.percentHold}%;background:${COLORS.hold}"></div>
        <div class="turnover-bar-segment" style="width:${data.percentNTO}%;background:${COLORS.nto}"></div>
      `;

      // Stats cards
      document.getElementById('stats-grid').innerHTML = `
        <div class="stat-card">
          <div class="stat-value">${commas(data.activeShots)}</div>
          <div class="stat-label">Active Shots</div>
        </div>
        <div class="stat-card">
          <div class="stat-value green">${commas(data.turnover.turnedOver)}</div>
          <div class="stat-label">Turned Over</div>
        </div>
        <div class="stat-card">
          <div class="stat-value">${commas(data.totalFrames)}</div>
          <div class="stat-label">Total Frames</div>
        </div>
        <div class="stat-card">
          <div class="stat-value blue">${data.totalMinutes} min</div>
          <div class="stat-label">Duration @24fps</div>
        </div>
      `;

      // Breakdown table
      const rows = [
        { cat: 'VFX Shots', shots: data.shotTypes.vfx, frames: data.shotTypes.vfxFrames, color: COLORS.vfx },
        { cat: 'NVX Shots', shots: data.shotTypes.nvx, frames: data.shotTypes.nvxFrames, color: COLORS.nvx },
      ];
      const tbody = document.getElementById('breakdown-body');
      tbody.innerHTML = rows.map(r => {
        const pct = data.activeShots ? ((r.shots / data.activeShots) * 100).toFixed(1) : 0;
        return `<tr>
          <td>${r.cat}</td>
          <td>${commas(r.shots)}</td>
          <td>${commas(r.frames)}</td>
          <td>${framesToTC(r.frames)}</td>
          <td><div class="pct-bar"><span class="pct-bar-fill" style="width:${pct}px;background:${r.color}"></span><span class="pct-text">${pct}%</span></div></td>
        </tr>`;
      }).join('') + `<tr class="total-row">
        <td>Total</td>
        <td>${commas(data.activeShots)}</td>
        <td>${commas(data.totalFrames)}</td>
        <td>${framesToTC(data.totalFrames)}</td>
        <td><span class="pct-text">100%</span></td>
      </tr>`;

      // Turnover detail table
      const tBody = document.getElementById('turnover-body');
      const turnoverRows = [
        { status: 'Turned Over', count: data.turnover.turnedOver, pct: data.percentTurnedOver, color: COLORS.turnedOver },
        { status: 'NTO (Not Turned Over)', count: data.turnover.nto, pct: data.percentNTO, color: COLORS.nto },
        { status: 'HOLD', count: data.turnover.hold, pct: data.percentHold, color: COLORS.hold },
      ];
      tBody.innerHTML = turnoverRows.map(r => `<tr>
        <td>${r.status}</td>
        <td>${commas(r.count)}</td>
        <td>${r.pct}%</td>
        <td><div class="pct-bar"><span class="pct-bar-fill" style="width:${Math.max(r.pct, 0.5)}px;background:${r.color}"></span></div></td>
      </tr>`).join('') + `<tr class="total-row">
        <td>Total Active</td>
        <td>${commas(data.activeShots)}</td>
        <td>100%</td>
        <td></td>
      </tr>
      <tr>
        <td style="color:#9ca3af;font-size:11px">Excluded (Inactive)</td>
        <td style="color:#9ca3af;font-size:11px">${commas(data.excludedInactive)}</td>
        <td style="color:#9ca3af;font-size:11px">—</td>
        <td></td>
      </tr>`;
    }

    // PDF Download
    async function downloadPDF() {
      const controls = document.getElementById('controls');
      controls.style.display = 'none';

      const el = document.getElementById('report');
      const canvas = await html2canvas(el, {
        scale: 2,
        useCORS: true,
        backgroundColor: '#ffffff',
      });

      const { jsPDF } = window.jspdf;
      const pdf = new jsPDF('p', 'in', 'letter');
      const imgData = canvas.toDataURL('image/jpeg', 0.95);

      const pdfW = 8.5;
      const pdfH = 11;
      const imgW = canvas.width;
      const imgH = canvas.height;
      const ratio = Math.min(pdfW / imgW, pdfH / imgH);
      const w = imgW * ratio;
      const h = imgH * ratio;
      const x = (pdfW - w) / 2;

      pdf.addImage(imgData, 'JPEG', x, 0, w, h);

      const today = new Date().toISOString().slice(0, 10);
      pdf.save(`Stereo_Report_${today}.pdf`);

      controls.style.display = 'flex';
    }

    loadReport();
  </script>
</body>
</html>