Untitled

 avatar
unknown
plain_text
23 days ago
58 kB
3
Indexable
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { deflateSync } from "node:zlib";

export type ReportRow = {
  label: string;
  detail: string;
  time: number;
  project: string;
};

// ─── Value coercion (shared with the route via re-export) ───────────────────

export function isRecord(value: unknown): value is Record<string, unknown> {
  return !!value && typeof value === "object" && !Array.isArray(value);
}

export function asString(value: unknown, fallback = "") {
  if (typeof value === "string") {
    return value.trim() || fallback;
  }
  if (typeof value === "number" && Number.isFinite(value)) {
    return String(value);
  }
  return fallback;
}

export function asNumber(value: unknown) {
  if (typeof value === "number" && Number.isFinite(value)) {
    return value;
  }
  if (typeof value === "string") {
    const durationSeconds = parseDurationString(value);
    if (durationSeconds > 0) return durationSeconds;
    const parsed = Number(value.replace(/,/g, ""));
    return Number.isFinite(parsed) ? parsed : 0;
  }
  return 0;
}

function normalizeDurationSeconds(value: number) {
  const duration = Math.max(0, value);
  return duration > 86_400 ? duration / 1000 : duration;
}

function normalizeAggregateDurationSeconds(value: number, totalSessions: number) {
  const duration = Math.max(0, value);
  if (duration <= 0 || totalSessions <= 0) return duration;

  const average = duration / totalSessions;
  const averageIfMs = duration / 1000 / totalSessions;
  if (average > 21_600 && averageIfMs > 0 && averageIfMs <= 21_600) {
    return duration / 1000;
  }

  return duration;
}

function parseDurationString(value: string) {
  const text = value.trim().toLowerCase();
  if (!text) return 0;

  const colonMatch = text.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?$/);
  if (colonMatch) {
    const first = Number(colonMatch[1]);
    const second = Number(colonMatch[2]);
    const third = Number(colonMatch[3] || 0);
    return colonMatch[3] ? first * 3600 + second * 60 + third : first * 60 + second;
  }

  let seconds = 0;
  const hours = text.match(/(\d+(?:\.\d+)?)\s*(?:h|hr|hrs|hour|hours)\b/);
  const minutes = text.match(/(\d+(?:\.\d+)?)\s*(?:m|min|mins|minute|minutes)\b/);
  const secs = text.match(/(\d+(?:\.\d+)?)\s*(?:s|sec|secs|second|seconds)\b/);
  if (hours) seconds += Number(hours[1]) * 3600;
  if (minutes) seconds += Number(minutes[1]) * 60;
  if (secs) seconds += Number(secs[1]);
  return Number.isFinite(seconds) ? seconds : 0;
}

// ─── Row / metric shaping ───────────────────────────────────────────────────

function looksLikeFlatLabel(value: string) {
  const normalized = value.trim();
  return (
    /^\d{2,5}[A-Z]?$/i.test(normalized) ||
    /\b[A-Z0-9]{1,4}-\d{2,5}[A-Z]?(?:-\d{1,3}[A-Z]?)?\b/i.test(normalized) ||
    /^(?:flat|unit|apartment)\s*[-#:]?\s*[A-Z]?\d{2,5}/i.test(normalized)
  );
}

function flatNumberFromText(value: string) {
  const normalized = value.trim();
  if (/^\d{2,5}[A-Z]?$/i.test(normalized)) return normalized.toUpperCase();

  const labeled = normalized.match(/^(?:flat|unit|apartment)\s*[-#:]?\s*([A-Z]?\d{2,5}[A-Z]?)/i);
  if (labeled) return labeled[1].toUpperCase();

  const hyphenated = normalized.match(/\b[A-Z0-9]{1,4}-\d{2,5}[A-Z]?(?:-\d{1,3}[A-Z]?)?\b/i);
  return hyphenated ? hyphenated[0].toUpperCase() : "";
}

function flatNumberForRow(row: ReportRow) {
  return flatNumberFromText(row.detail) || flatNumberFromText(row.label);
}

function looksLikeFlatEvent(row: Record<string, unknown>) {
  const eventType = asString(row.event_type ?? row.type ?? row.event ?? row.name).toLowerCase();
  const detail = asString(row.event_detail ?? row.detail ?? row.label ?? row.view);
  return looksLikeFlatLabel(detail) || /\b(flat|unit|apartment|cut section)\b/i.test(eventType);
}

function normalizeRow(row: unknown, fallbackProject: string): ReportRow | null {
  if (!isRecord(row)) return null;
  const label = asString(
    row.event_type ?? row.type ?? row.name ?? row.view ?? row.title ?? row.flat_number ?? row.flat_no ?? row.unit_number ?? row.event_detail ?? row.detail,
    "Unknown"
  );
  const detail = asString(
    row.flat_number ?? row.flat_no ?? row.unit_number ?? row.unit ?? row.event_detail ?? row.detail ?? row.flat ?? row.label,
    ""
  );
  const time = normalizeDurationSeconds(
    asNumber(row.time ?? row.duration ?? row.total_duration ?? row.total_duration_sec ?? row.seconds ?? row.value)
  );
  const project = asString(row.project ?? row.project_name ?? row.project_id, fallbackProject);
  return { label, detail, time, project };
}

function normalizeRows(rawRows: unknown, fallbackProject: string) {
  if (!Array.isArray(rawRows)) return [];
  return rawRows
    .map((row) => normalizeRow(row, fallbackProject))
    .filter((row): row is ReportRow => !!row)
    .sort((a, b) => b.time - a.time);
}

function pickMetric(
  data: Record<string, unknown>,
  report: Record<string, unknown> | null,
  key: string
) {
  const kpis = report && isRecord(report.kpis) ? report.kpis : {};
  return asNumber(kpis[key] ?? report?.[key] ?? data[key]);
}

function maxDurationFromRows(value: unknown) {
  if (!Array.isArray(value)) return 0;
  return Math.max(
    0,
    ...value.map((item) => {
      if (!isRecord(item)) return 0;
      return normalizeDurationSeconds(
        asNumber(
          item.duration ??
            item.duration_sec ??
            item.duration_seconds ??
            item.total_duration ??
            item.total_duration_sec ??
            item.time ??
            item.seconds
        )
      );
    })
  );
}

function sessionTimestampFromRecord(item: Record<string, unknown>) {
  return asString(
    item.timestamp ??
      item.created_at ??
      item.started_at ??
      item.start_time ??
      item.startTime ??
      item.session_start ??
      item.session_started ??
      item.sessionStart ??
      item.sessionStarted ??
      item.started ??
      item.start ??
      item.date_time ??
      item.datetime ??
      item.time_started ??
      item.started_time ??
      item.date
  );
}

function normalizeSessionRows(value: unknown) {
  if (!Array.isArray(value)) return [];
  return value
    .map((item): ReportSession | null => {
      if (!isRecord(item)) return null;
      const duration = normalizeDurationSeconds(
        asNumber(
          item.duration ??
            item.duration_sec ??
            item.duration_seconds ??
            item.total_duration ??
            item.total_duration_sec ??
            item.time ??
            item.durationSeconds ??
            item.duration_seconds ??
            item.seconds
        )
      );
      if (duration <= 0) return null;
      return {
        duration,
        timestamp: sessionTimestampFromRecord(item),
      };
    })
    .filter((item): item is ReportSession => Boolean(item))
    .sort((left, right) => right.duration - left.duration);
}

function uniqueSessions(sessions: ReportSession[]) {
  const seen = new Set<string>();
  return sessions.filter((session) => {
    const key = `${session.timestamp}|${Math.round(session.duration)}`.toLowerCase();
    if (seen.has(key)) return false;
    seen.add(key);
    return true;
  });
}

function collectTopSessions(source: Record<string, unknown> | null) {
  if (!source) return [];
  return uniqueSessions(
    [
      ...normalizeSessionRows(source.longest_sessions),
      ...normalizeSessionRows(source.longest_sessions_top3),
      ...normalizeSessionRows(source.top_sessions),
      ...normalizeSessionRows(source.sessions),
    ].sort((left, right) => right.duration - left.duration)
  );
}

function pickDirectLongestSessionDuration(source: Record<string, unknown> | null) {
  if (!source) return 0;
  return normalizeDurationSeconds(
    pickMetric({}, source, "longest_session_duration") ||
      pickMetric({}, source, "longest_duration") ||
      pickMetric({}, source, "max_session_duration") ||
      pickMetric({}, source, "longest_session_duration_sec") ||
      pickMetric({}, source, "longest_session_duration_seconds")
  );
}

function pickTopSessions(data: Record<string, unknown>, report: Record<string, unknown> | null, topViews: ReportRow[]) {
  const reportSessions = collectTopSessions(report);
  if (reportSessions.length) return reportSessions.slice(0, 5);

  if (report) {
    const direct = pickDirectLongestSessionDuration(report);
    return direct > 0 ? [{ duration: direct, timestamp: "" }] : [];
  }

  const dataSessions = collectTopSessions(data);
  if (dataSessions.length) return dataSessions.slice(0, 5);

  const direct = pickLongestSessionDuration(data, report, topViews);
  return direct > 0 ? [{ duration: direct, timestamp: "" }] : [];
}

function pickLongestSessionDuration(
  data: Record<string, unknown>,
  report: Record<string, unknown> | null,
  topViews: ReportRow[]
) {
  const direct = normalizeDurationSeconds(
    pickMetric(data, report, "longest_session_duration") ||
      pickMetric(data, report, "longest_duration") ||
      pickMetric(data, report, "max_session_duration") ||
      pickMetric(data, report, "longest_session_duration_sec") ||
      pickMetric(data, report, "longest_session_duration_seconds")
  );
  if (direct > 0) return direct;

  const fromArrays = Math.max(
    maxDurationFromRows(report?.longest_sessions),
    maxDurationFromRows(report?.longest_sessions_top3),
    maxDurationFromRows(data.longest_sessions),
    maxDurationFromRows(data.longest_sessions_top3)
  );
  if (fromArrays > 0) return fromArrays;

  return topViews[0]?.time || 0;
}

function secondsLabel(seconds: number) {
  const value = Math.max(0, Math.round(seconds));
  const pad = (n: number) => String(n).padStart(2, "0");
  const hours = Math.floor(value / 3600);
  const minutes = Math.floor((value % 3600) / 60);
  const secs = value % 60;
  if (hours) return `${pad(hours)}h ${pad(minutes)}m ${pad(secs)}s`;
  if (minutes) return `${pad(minutes)}m ${pad(secs)}s`;
  return `${pad(secs)}s`;
}

function titleCase(value: string) {
  return value.replace(/\w\S*/g, (word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
}

function getTopViewRows(
  data: Record<string, unknown>,
  report: Record<string, unknown> | null,
  fallbackProject: string
) {
  if (report) {
    const reportRows = normalizeRows(report.top_views ?? report.views, fallbackProject);
    const seen = new Set<string>();
    const nonFlatRows = reportRows.filter((row) => {
      const key = `${row.label}|${row.detail}`.toLowerCase();
      if (seen.has(key)) return false;
      seen.add(key);
      return !looksLikeFlatEvent({ event_type: row.label, event_detail: row.detail });
    });
    return (nonFlatRows.length ? nonFlatRows : reportRows).slice(0, 10);
  }

  const rows = [
    ...normalizeRows(data.top_views ?? data.views, fallbackProject),
  ];
  const seen = new Set<string>();
  const nonFlatRows = rows.filter((row) => {
    const key = `${row.label}|${row.detail}`.toLowerCase();
    if (seen.has(key)) return false;
    seen.add(key);
    return !looksLikeFlatEvent({ event_type: row.label, event_detail: row.detail });
  });
  return (nonFlatRows.length ? nonFlatRows : rows).slice(0, 10);
}

function getTopFlatRows(
  data: Record<string, unknown>,
  report: Record<string, unknown> | null,
  fallbackProject: string
) {
  if (report) {
    const explicitReportRows = normalizeRows(report.top_flats ?? report.top_5_flats, fallbackProject).filter(
      (row) => flatNumberForRow(row)
    );
    if (explicitReportRows.length) return explicitReportRows.slice(0, 10);

    return normalizeRows(report.top_views ?? report.views, fallbackProject)
      .filter((row) => flatNumberForRow(row) && looksLikeFlatEvent({ event_type: row.label, event_detail: row.detail }))
      .slice(0, 10);
  }

  const explicitRows = normalizeRows(
    data.top_flats ?? data.top_5_flats,
    fallbackProject
  ).filter((row) => flatNumberForRow(row));
  if (explicitRows.length) return explicitRows.slice(0, 10);

  return normalizeRows(data.top_views ?? data.views, fallbackProject)
    .filter((row) => flatNumberForRow(row) && looksLikeFlatEvent({ event_type: row.label, event_detail: row.detail }))
    .slice(0, 10);
}

// ═══════════════════════════════════════════════════════════════════════════
//  PDF drawing primitives
// ═══════════════════════════════════════════════════════════════════════════

// A4 landscape
const PAGE_WIDTH = 842;
const PAGE_HEIGHT = 595;
const PAGE_CENTER_X = PAGE_WIDTH / 2;
const LOGO_PATH = join(process.cwd(), "Navigo_Intelligence_Logo.svg");
const LOGO_SVG = readFileSync(LOGO_PATH, "utf8");
const LOGO_PATHS = [...LOGO_SVG.matchAll(/<path\b[^>]*\bd="([^"]+)"/g)].map((match) => match[1]);
const LOGO_CIRCLES = [...LOGO_SVG.matchAll(/<circle\b[^>]*\bcx="([^"]+)"\s+cy="([^"]+)"\s+r="([^"]+)"/g)].map(
  (match) => ({
    cx: Number(match[1]),
    cy: Number(match[2]),
    r: Number(match[3]),
  })
);
const LOGO_CROP = { x: 225, y: 278, w: 410, h: 154 };
const PUBLIC_SANS_REGULAR = readFileSync(join(process.cwd(), "public/fonts/PublicSans-Regular.ttf"));
const PUBLIC_SANS_SEMIBOLD = readFileSync(join(process.cwd(), "public/fonts/PublicSans-SemiBold.ttf"));

type TtfMetrics = {
  unitsPerEm: number;
  ascent: number;
  descent: number;
  capHeight: number;
  bbox: [number, number, number, number];
  widths: number[];
  widthByCode: Map<number, number>;
};

function parseTtfMetrics(font: Buffer): TtfMetrics {
  const tableCount = font.readUInt16BE(4);
  const tables = new Map<string, { offset: number; length: number }>();
  for (let i = 0; i < tableCount; i++) {
    const offset = 12 + i * 16;
    const tag = font.toString("ascii", offset, offset + 4);
    tables.set(tag, {
      offset: font.readUInt32BE(offset + 8),
      length: font.readUInt32BE(offset + 12),
    });
  }

  const table = (tag: string) => {
    const found = tables.get(tag);
    if (!found) throw new Error(`Missing ${tag} table in Public Sans font`);
    return found;
  };
  const head = table("head").offset;
  const hhea = table("hhea").offset;
  const maxp = table("maxp").offset;
  const hmtx = table("hmtx").offset;

  const unitsPerEm = font.readUInt16BE(head + 18);
  const bbox: [number, number, number, number] = [
    font.readInt16BE(head + 36),
    font.readInt16BE(head + 38),
    font.readInt16BE(head + 40),
    font.readInt16BE(head + 42),
  ];
  const ascent = font.readInt16BE(hhea + 4);
  const descent = font.readInt16BE(hhea + 6);
  const numLongHorMetrics = font.readUInt16BE(hhea + 34);
  const numGlyphs = font.readUInt16BE(maxp + 4);
  const advanceForGlyph = (glyphId: number) => {
    if (glyphId < numLongHorMetrics) return font.readUInt16BE(hmtx + glyphId * 4);
    return font.readUInt16BE(hmtx + (numLongHorMetrics - 1) * 4);
  };

  const glyphForCode = buildCmapLookup(font, tables);
  const widths: number[] = [];
  const widthByCode = new Map<number, number>();
  for (let code = 32; code <= 255; code++) {
    const glyphId = Math.min(glyphForCode(code), numGlyphs - 1);
    const width = Math.round((advanceForGlyph(glyphId) / unitsPerEm) * 1000);
    widths.push(width);
    widthByCode.set(code, width);
  }

  return {
    unitsPerEm,
    ascent: Math.round((ascent / unitsPerEm) * 1000),
    descent: Math.round((descent / unitsPerEm) * 1000),
    capHeight: Math.round((ascent / unitsPerEm) * 700),
    bbox: bbox.map((value) => Math.round((value / unitsPerEm) * 1000)) as [number, number, number, number],
    widths,
    widthByCode,
  };
}

function buildCmapLookup(font: Buffer, tables: Map<string, { offset: number; length: number }>) {
  const cmap = tables.get("cmap");
  if (!cmap) return () => 0;

  const subtables = font.readUInt16BE(cmap.offset + 2);
  let subtableOffset = 0;
  for (let i = 0; i < subtables; i++) {
    const record = cmap.offset + 4 + i * 8;
    const platformId = font.readUInt16BE(record);
    const encodingId = font.readUInt16BE(record + 2);
    const offset = cmap.offset + font.readUInt32BE(record + 4);
    const format = font.readUInt16BE(offset);
    if (format === 4 && platformId === 3 && (encodingId === 1 || encodingId === 0)) {
      subtableOffset = offset;
      break;
    }
    if (!subtableOffset && format === 4) subtableOffset = offset;
  }

  if (!subtableOffset) return () => 0;

  const segCount = font.readUInt16BE(subtableOffset + 6) / 2;
  const endCodeOffset = subtableOffset + 14;
  const startCodeOffset = endCodeOffset + segCount * 2 + 2;
  const idDeltaOffset = startCodeOffset + segCount * 2;
  const idRangeOffsetOffset = idDeltaOffset + segCount * 2;

  return (code: number) => {
    for (let i = 0; i < segCount; i++) {
      const endCode = font.readUInt16BE(endCodeOffset + i * 2);
      const startCode = font.readUInt16BE(startCodeOffset + i * 2);
      if (code < startCode || code > endCode) continue;

      const idDelta = font.readInt16BE(idDeltaOffset + i * 2);
      const idRangeOffset = font.readUInt16BE(idRangeOffsetOffset + i * 2);
      if (idRangeOffset === 0) return (code + idDelta) & 0xffff;

      const glyphOffset = idRangeOffsetOffset + i * 2 + idRangeOffset + (code - startCode) * 2;
      const glyph = font.readUInt16BE(glyphOffset);
      return glyph === 0 ? 0 : (glyph + idDelta) & 0xffff;
    }
    return 0;
  };
}

const PUBLIC_SANS_REGULAR_METRICS = parseTtfMetrics(PUBLIC_SANS_REGULAR);
const PUBLIC_SANS_SEMIBOLD_METRICS = parseTtfMetrics(PUBLIC_SANS_SEMIBOLD);

// ── Palette ────────────────────────────────────────────────────────────────
// Panels are #F7F7F7 frosted "liquid glass" — a light, translucent material
// over the blue field. Text stays a light, airy white hierarchy so it reads
// over the frosted panels without feeling heavy.
const F7 = [0.969, 0.969, 0.969] as const; // #F7F7F7 — the glass tint
const WHITE = [0.99, 0.99, 1.0] as const; // primary — values, titles, logo
const MUTED = [0.88, 0.92, 1.0] as const; // secondary — metric labels
const FAINT = [0.8, 0.86, 0.99] as const; // tertiary — column headers
const PANEL = F7;
const PANEL_BORDER = [1.0, 1.0, 1.0] as const; // soft white edge
const HAIRLINE = [1.0, 1.0, 1.0] as const; // faint dividers
const ROW_HIGHLIGHT = [1.0, 1.0, 1.0] as const; // barely-there row tint

// Shared size for every box/column header label across both pages.
const HEADER_LABEL_SIZE = 8;

const PAGE_EXTGSTATE =
  "<< " +
  "/GP << /Type /ExtGState /ca 0.14 >> " + // frosted glass body (light, translucent)
  "/GR << /Type /ExtGState /ca 0.07 >> " + // barely-there alternating row tint
  "/GG << /Type /ExtGState /ca 0.10 >> " +
  "/GLO << /Type /ExtGState /ca 0.42 >> " +
  "/GLS << /Type /ExtGState /ca 0.24 >> " +
  "/GDR << /Type /ExtGState /ca 0.5 >> " + // edge vignette
  "/GBW << /Type /ExtGState /ca 0.34 >> " +
  "/GHERO << /Type /ExtGState /ca 0.92 >> " + // electric-blue hero glow
  "/GHL2 << /Type /ExtGState /ca 0.5 >> " + // secondary depth highlight
  "/GB2 << /Type /ExtGState /ca 0.24 >> " +
  "/GDS << /Type /ExtGState /ca 0.38 >> " +
  "/GD2 << /Type /ExtGState /ca 0.34 >> " + // top-right darkening
  "/GED << /Type /ExtGState /ca 0.45 >> " +
  "/GSP << /Type /ExtGState /ca 0.97 >> " +
  "/GSH << /Type /ExtGState /ca 0.12 >> " +
  "/GCA << /Type /ExtGState /ca 0.9 >> " +
  "/GST << /Type /ExtGState /CA 0.42 >> " + // soft white panel border stroke
  "/GTOP << /Type /ExtGState /CA 0.62 >> " + // bright liquid-glass top rim
  "/GHL << /Type /ExtGState /ca 0.16 /CA 0.22 >> " +
  "/GGR << /Type /ExtGState /BM /SoftLight /ca 1 >> " +
  ">>";

// ── Shadings (inline; unit-space ones are placed via `cm`) ─────────────────
function fmt(n: number) {
  return Number.isInteger(n) ? String(n) : Number(n.toFixed(4)).toString();
}

function rgb(c: readonly number[]) {
  return c.map(fmt).join(" ");
}

function hex(value: `#${string}`) {
  const normalized = value.slice(1);
  if (!/^[0-9a-f]{6}$/i.test(normalized)) {
    throw new Error(`Invalid hex color: ${value}`);
  }
  return [
    parseInt(normalized.slice(0, 2), 16) / 255,
    parseInt(normalized.slice(2, 4), 16) / 255,
    parseInt(normalized.slice(4, 6), 16) / 255,
  ] as const;
}

// Stitched exponential interpolation across colour stops → a /Function dict.
function stitchFunction(stops: { t: number; c: readonly number[] }[]) {
  if (stops.length === 2) {
    return `<< /FunctionType 2 /Domain [0 1] /C0 [${rgb(stops[0].c)}] /C1 [${rgb(stops[1].c)}] /N 1 >>`;
  }
  const subs: string[] = [];
  const bounds: number[] = [];
  const encode: string[] = [];
  for (let i = 0; i < stops.length - 1; i++) {
    subs.push(`<< /FunctionType 2 /Domain [0 1] /C0 [${rgb(stops[i].c)}] /C1 [${rgb(stops[i + 1].c)}] /N 1 >>`);
    if (i > 0) bounds.push(stops[i].t);
    encode.push("0 1");
  }
  return `<< /FunctionType 3 /Domain [0 1] /Functions [${subs.join(" ")}] /Bounds [${bounds.join(" ")}] /Encode [${encode.join(" ")}] >>`;
}

function radial(coords: number[], stops: { t: number; c: readonly number[] }[], extend = true) {
  const ext = extend ? "[true true]" : "[false false]";
  return `<< /ShadingType 3 /ColorSpace /DeviceRGB /Coords [${coords.map(fmt).join(" ")}] /Extend ${ext} /Function ${stitchFunction(stops)} >>`;
}

function axial(coords: number[], stops: { t: number; c: readonly number[] }[], extend = true) {
  const ext = extend ? "[true true]" : "[false false]";
  return `<< /ShadingType 2 /ColorSpace /DeviceRGB /Coords [${coords.map(fmt).join(" ")}] /Extend ${ext} /Function ${stitchFunction(stops)} >>`;
}

// Base sweep — bright electric royal blue with enough depth for white text.
const SH_BASE_SWEEP = axial(
  [PAGE_WIDTH, PAGE_HEIGHT, 0, 0],
  [
    { t: 0.0, c: hex("#070455") },
    { t: 0.48, c: hex("#030f93") },
    { t: 1.0, c: hex("#0d29ff") },
  ]  
);

// Hero glow — the bright electric-blue light, centred slightly left of centre
// and vertically centred. Drawn as a large ellipse so it reads as a soft band
// of light rather than a hard spot.
const SH_HERO = radial(
  [0, 0, 0, 0, 0, 1],
  [
    { t: 0.0, c: [0.16, 0.49, 1.0] },
    { t: 0.32, c: [0.07, 0.3, 0.9] },
    { t: 0.66, c: [0.03, 0.13, 0.55] },
    { t: 1.0, c: [0.015, 0.04, 0.2] },
  ]
);

// Secondary highlight — a cooler, smaller glow up and to the left of the hero,
// giving the light field a sense of depth.
const SH_DEPTH = radial(
  [0, 0, 0, 0, 0, 1],
  [
    { t: 0.0, c: [0.12, 0.38, 0.92] },
    { t: 0.55, c: [0.04, 0.16, 0.6] },
    { t: 1.0, c: [0.02, 0.05, 0.22] },
  ]
);

// Top-right darkening — pulls the brightest corner of the base sweep back down
// so the composition stays weighted toward the centre-left light.
const SH_TOPRIGHT = radial(
  [0, 0, 0, 0, 0, 1],
  [
    { t: 0.0, c: [0.004, 0.008, 0.05] },
    { t: 0.6, c: [0.006, 0.014, 0.08] },
    { t: 1.0, c: [0.014, 0.038, 0.17] },
  ]
);

// Edge vignette — keeps the royal blue dimensional without falling to black.
const SH_EDGE = radial(
  [421, 298, 150, 421, 298, 620],
  [
    { t: 0.0, c: hex("#168CFF") },
    { t: 0.58, c: hex("#0B45D8") },
    { t: 1.0, c: hex("#06237A") },
  ]
);

// Liquid-glass gloss — a vertical sheen in unit space (y=1 top → y=0 bottom),
// brightest at the top edge and easing into the #F7F7F7 frost. Placed onto each
// panel with `cm`, clipped to its rounded rect, so the body reads as glass.
const SH_GLASS = axial(
  [0, 1, 0, 0],
  [
    { t: 0.0, c: hex("#FFFFFF") },
    { t: 0.18, c: hex("#EAF3FF") },
    { t: 0.58, c: hex("#BED6FF") },
    { t: 1.0, c: hex("#86AFFF") },
  ]
);

const SH_SPHERE = radial(
  [-0.42, 0.38, 0.0, 0, 0, 1.0],
  [
    { t: 0.0, c: [0.97, 0.97, 1.0] },
    { t: 0.24, c: [0.88, 0.90, 1.0] },
    { t: 0.58, c: [0.55, 0.50, 0.98] },
    { t: 1.0, c: [0.14, 0.10, 0.43] },
  ]
);

const SH_GLOW = radial(
  [0, 0, 0, 0, 0, 1.0],
  [
    { t: 0.0, c: [0.45, 0.6, 1.0] },
    { t: 0.6, c: [0.2, 0.34, 0.86] },
    { t: 1.0, c: [0.11, 0.22, 0.68] },
  ]
);

const SH_SHADOW = radial(
  [0, 0, 0, 0, 0, 1.0],
  [
    { t: 0.0, c: [0.06, 0.04, 0.22] },
    { t: 1.0, c: [0.04, 0.10, 0.62] },
  ]
);

const PAGE_SHADINGS =
  `<< /BaseSweep ${SH_BASE_SWEEP} /Hero ${SH_HERO} /Depth ${SH_DEPTH} ` +
  `/TopRight ${SH_TOPRIGHT} /Edge ${SH_EDGE} /Glass ${SH_GLASS} ` +
  `/Sphere ${SH_SPHERE} /Glow ${SH_GLOW} /Shadow ${SH_SHADOW} >>`;

// ── Low-level emit helpers ─────────────────────────────────────────────────

// Unicode punctuation → WinAnsi byte (the 0x80–0x9F slots the fonts use below).
const WINANSI_PUNCT: Record<number, number> = {
  0x20ac: 0x80, // €
  0x2018: 0x91, // ‘
  0x2019: 0x92, // ’
  0x201c: 0x93, // “
  0x201d: 0x94, // ”
  0x2022: 0x95, // •
  0x2013: 0x96, // –
  0x2014: 0x97, // —
  0x2026: 0x85, // …
  0x2122: 0x99, // ™
};

// Encode a JS string for a WinAnsi-encoded base-14 font: escape PDF delimiters,
// pass through printable ASCII, map known Unicode punctuation + Latin-1 to their
// single-byte octal escapes, and fall back to "-" for anything unrepresentable.
function pdfString(text: string) {
  let out = "";
  for (const ch of text) {
    const code = ch.codePointAt(0) ?? 0;
    if (ch === "\\") out += "\\\\";
    else if (ch === "(") out += "\\(";
    else if (ch === ")") out += "\\)";
    else if (code >= 32 && code <= 126) out += ch;
    else {
      const byte = code >= 0xa0 && code <= 0xff ? code : WINANSI_PUNCT[code];
      out += byte == null ? "-" : `\\${byte.toString(8).padStart(3, "0")}`;
    }
  }
  return out;
}

function makeContentStream(lines: string[]) {
  return lines.join("\n");
}

type TextOpts = {
  size?: number;
  font?: "F1" | "F2";
  fill?: readonly number[];
  tracking?: number;
};

function emitText(lines: string[], x: number, y: number, text: string, opts: TextOpts = {}) {
  const { size = 10, font = "F1", fill = WHITE, tracking = 0 } = opts;
  lines.push(
    `${rgb(fill)} rg BT /${font} ${fmt(size)} Tf ${fmt(tracking)} Tc ${fmt(x)} ${fmt(y)} Td (${pdfString(text)}) Tj ET`
  );
}

// Helvetica glyph advance widths (units / 1000 em) for ASCII 32–126. Both PDF
// fonts are Helvetica, so this gives true text widths — needed for accurate
// centering and right-alignment.
const HELVETICA_WIDTHS = [
  278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, // 32–47
  556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, // 48–63
  1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, // 64–79
  667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, // 80–95
  333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, // 96–111
  556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584, // 112–126
];

function metricsForFont(font: "F1" | "F2") {
  return font === "F2" ? PUBLIC_SANS_SEMIBOLD_METRICS : PUBLIC_SANS_REGULAR_METRICS;
}

function charWidth(code: number, font: "F1" | "F2") {
  const metrics = metricsForFont(font);
  const winAnsiCode = code >= 32 && code <= 255 ? code : WINANSI_PUNCT[code];
  if (winAnsiCode != null) return metrics.widthByCode.get(winAnsiCode) ?? 556;
  if (code >= 32 && code <= 126) return HELVETICA_WIDTHS[code - 32];
  return 556; // sensible default for anything else
}

function textWidth(text: string, size: number, font: "F1" | "F2", tracking = 0) {
  let units = 0;
  let count = 0;
  for (const ch of text) {
    units += charWidth(ch.codePointAt(0) ?? 0, font);
    count++;
  }
  return (units / 1000) * size + Math.max(0, count - 1) * tracking;
}

function emitCenteredText(lines: string[], cx: number, y: number, text: string, opts: TextOpts = {}) {
  const w = textWidth(text, opts.size ?? 10, opts.font ?? "F1", opts.tracking ?? 0);
  emitText(lines, cx - w / 2, y, text, opts);
}

function emitRightText(lines: string[], xRight: number, y: number, text: string, opts: TextOpts = {}) {
  const w = textWidth(text, opts.size ?? 10, opts.font ?? "F1", opts.tracking ?? 0);
  emitText(lines, xRight - w, y, text, opts);
}

function reportTitle(title: string, startDate: string, endDate: string) {
  return `${title} - ${formatSessionDate(startDate)} to ${formatSessionDate(endDate)}`;
}

function fillRect(lines: string[], x: number, y: number, w: number, h: number, fill: readonly number[]) {
  lines.push(`${rgb(fill)} rg ${fmt(x)} ${fmt(y)} ${fmt(w)} ${fmt(h)} re f`);
}

function emitLine(
  lines: string[],
  x1: number,
  y1: number,
  x2: number,
  y2: number,
  stroke: readonly number[],
  width = 0.75,
  alphaState?: string
) {
  if (alphaState) lines.push(`q /${alphaState} gs`);
  lines.push(`${rgb(stroke)} RG ${fmt(width)} w ${fmt(x1)} ${fmt(y1)} m ${fmt(x2)} ${fmt(y2)} l S`);
  if (alphaState) lines.push("Q");
}

const KAPPA = 0.5522847498;

function roundedRectPath(x: number, y: number, w: number, h: number, r: number) {
  const k = r * KAPPA;
  return [
    `${fmt(x + r)} ${fmt(y)} m`,
    `${fmt(x + w - r)} ${fmt(y)} l`,
    `${fmt(x + w - r + k)} ${fmt(y)} ${fmt(x + w)} ${fmt(y + r - k)} ${fmt(x + w)} ${fmt(y + r)} c`,
    `${fmt(x + w)} ${fmt(y + h - r)} l`,
    `${fmt(x + w)} ${fmt(y + h - r + k)} ${fmt(x + w - r + k)} ${fmt(y + h)} ${fmt(x + w - r)} ${fmt(y + h)} c`,
    `${fmt(x + r)} ${fmt(y + h)} l`,
    `${fmt(x + r - k)} ${fmt(y + h)} ${fmt(x)} ${fmt(y + h - r + k)} ${fmt(x)} ${fmt(y + h - r)} c`,
    `${fmt(x)} ${fmt(y + r)} l`,
    `${fmt(x)} ${fmt(y + r - k)} ${fmt(x + r - k)} ${fmt(y)} ${fmt(x + r)} ${fmt(y)} c`,
    "h",
  ].join(" ");
}

// A frosted "liquid glass" panel: a translucent #F7F7F7 body with a vertical
// gloss (bright at the top, easing down), a soft low-opacity border, and a
// crisp top rim light. No hard double outline — it reads as soft glass.
function glassPanel(lines: string[], x: number, y: number, w: number, h: number, r: number) {
  const path = roundedRectPath(x, y, w, h, r);

  // Soft shadow behind the panel so it feels lifted from the page.
  lines.push(`q /GSH gs 0 0 0 rg ${roundedRectPath(x + 2.2, y - 3.2, w, h, r)} f Q`);

  // Frosted body: the gloss shading clipped to the rounded rect, at low alpha
  // so the blue field still shows through the glass.
  lines.push(
    `q /GP gs ${path} W n ${fmt(w)} 0 0 ${fmt(h)} ${fmt(x)} ${fmt(y)} cm /Glass sh Q`
  );

  // Soft outer border — barely-there white edge instead of a hard outline.
  lines.push(`q /GST gs ${rgb(PANEL_BORDER)} RG 0.7 w ${path} S Q`);
}

const UNIT_CIRCLE =
  `1 0 m ` +
  `1 ${fmt(KAPPA)} ${fmt(KAPPA)} 1 0 1 c ` +
  `${fmt(-KAPPA)} 1 -1 ${fmt(KAPPA)} -1 0 c ` +
  `-1 ${fmt(-KAPPA)} ${fmt(-KAPPA)} -1 0 -1 c ` +
  `${fmt(KAPPA)} -1 1 ${fmt(-KAPPA)} 1 0 c h`;

function paintShading(
  lines: string[],
  cx: number,
  cy: number,
  r: number,
  shadingName: string,
  alphaState: string
) {
  lines.push(`q /${alphaState} gs ${fmt(r)} 0 0 ${fmt(r)} ${fmt(cx)} ${fmt(cy)} cm ${UNIT_CIRCLE} W n /${shadingName} sh Q`);
}

function paintEllipseShading(
  lines: string[],
  cx: number,
  cy: number,
  rx: number,
  ry: number,
  shadingName: string,
  alphaState: string,
  rotate = 0
) {
  const cos = Math.cos((rotate * Math.PI) / 180);
  const sin = Math.sin((rotate * Math.PI) / 180);

  lines.push(
    `q /${alphaState} gs ` +
      `${fmt(rx * cos)} ${fmt(rx * sin)} ${fmt(-ry * sin)} ${fmt(ry * cos)} ${fmt(cx)} ${fmt(cy)} cm ` +
      `${UNIT_CIRCLE} W n /${shadingName} sh Q`
  );
}

function addSphere(lines: string[], cx: number, cy: number, r: number) {
  paintShading(lines, cx, cy, r, "Sphere", "GSP");
}

function addGlow(lines: string[], cx: number, cy: number, r: number, soft = false) {
  paintShading(lines, cx, cy, r, "Glow", soft ? "GLS" : "GLO");
}

function fillEllipse(
  lines: string[],
  cx: number,
  cy: number,
  rx: number,
  ry: number,
  fill: readonly number[],
  alphaState: string,
  rotate = 0
) {
  const cos = Math.cos((rotate * Math.PI) / 180);
  const sin = Math.sin((rotate * Math.PI) / 180);
  lines.push(
    `q /${alphaState} gs ${rgb(fill)} rg ` +
      `${fmt(rx * cos)} ${fmt(rx * sin)} ${fmt(-ry * sin)} ${fmt(ry * cos)} ${fmt(cx)} ${fmt(cy)} cm ` +
      `${UNIT_CIRCLE} f Q`
  );
}

function tokenizeSvgPath(d: string) {
  return d.match(/[MLHVCSQTAZmlhvcsqtaz]|-?\d*\.?\d+(?:e[-+]?\d+)?/g) ?? [];
}

function svgPoint(x: number, y: number, originX: number, originY: number, scale: number) {
  return {
    x: originX + (x - LOGO_CROP.x) * scale,
    y: originY + (LOGO_CROP.h - (y - LOGO_CROP.y)) * scale,
  };
}

function svgPathToPdfPath(d: string, originX: number, originY: number, scale: number) {
  const tokens = tokenizeSvgPath(d);
  const out: string[] = [];
  let i = 0;
  let cmd = "";
  let currentX = 0;
  let currentY = 0;

  const isCommand = (value: string | undefined) => !!value && /^[A-Za-z]$/.test(value);
  const num = () => Number(tokens[i++]);
  const point = (x: number, y: number) => svgPoint(x, y, originX, originY, scale);

  while (i < tokens.length) {
    if (isCommand(tokens[i])) cmd = tokens[i++];
    const relative = cmd === cmd.toLowerCase();
    const op = cmd.toUpperCase();

    if (op === "M") {
      let first = true;
      while (i < tokens.length && !isCommand(tokens[i])) {
        const rawX = num();
        const rawY = num();
        currentX = relative ? currentX + rawX : rawX;
        currentY = relative ? currentY + rawY : rawY;
        const p = point(currentX, currentY);
        out.push(`${fmt(p.x)} ${fmt(p.y)} ${first ? "m" : "l"}`);
        first = false;
      }
      continue;
    }

    if (op === "L") {
      while (i < tokens.length && !isCommand(tokens[i])) {
        const rawX = num();
        const rawY = num();
        currentX = relative ? currentX + rawX : rawX;
        currentY = relative ? currentY + rawY : rawY;
        const p = point(currentX, currentY);
        out.push(`${fmt(p.x)} ${fmt(p.y)} l`);
      }
      continue;
    }

    if (op === "H") {
      while (i < tokens.length && !isCommand(tokens[i])) {
        const rawX = num();
        currentX = relative ? currentX + rawX : rawX;
        const p = point(currentX, currentY);
        out.push(`${fmt(p.x)} ${fmt(p.y)} l`);
      }
      continue;
    }

    if (op === "V") {
      while (i < tokens.length && !isCommand(tokens[i])) {
        const rawY = num();
        currentY = relative ? currentY + rawY : rawY;
        const p = point(currentX, currentY);
        out.push(`${fmt(p.x)} ${fmt(p.y)} l`);
      }
      continue;
    }

    if (op === "C") {
      while (i < tokens.length && !isCommand(tokens[i])) {
        const x1 = relative ? currentX + num() : num();
        const y1 = relative ? currentY + num() : num();
        const x2 = relative ? currentX + num() : num();
        const y2 = relative ? currentY + num() : num();
        const x = relative ? currentX + num() : num();
        const y = relative ? currentY + num() : num();
        const p1 = point(x1, y1);
        const p2 = point(x2, y2);
        const p = point(x, y);
        out.push(`${fmt(p1.x)} ${fmt(p1.y)} ${fmt(p2.x)} ${fmt(p2.y)} ${fmt(p.x)} ${fmt(p.y)} c`);
        currentX = x;
        currentY = y;
      }
      continue;
    }

    if (op === "Z") {
      out.push("h");
      continue;
    }

    break;
  }

  return out.join(" ");
}

function circlePath(cx: number, cy: number, r: number, originX: number, originY: number, scale: number) {
  const center = svgPoint(cx, cy, originX, originY, scale);
  const radius = r * scale;
  return [
    `${fmt(center.x + radius)} ${fmt(center.y)} m`,
    `${fmt(center.x + radius)} ${fmt(center.y + radius * KAPPA)} ${fmt(center.x + radius * KAPPA)} ${fmt(center.y + radius)} ${fmt(center.x)} ${fmt(center.y + radius)} c`,
    `${fmt(center.x - radius * KAPPA)} ${fmt(center.y + radius)} ${fmt(center.x - radius)} ${fmt(center.y + radius * KAPPA)} ${fmt(center.x - radius)} ${fmt(center.y)} c`,
    `${fmt(center.x - radius)} ${fmt(center.y - radius * KAPPA)} ${fmt(center.x - radius * KAPPA)} ${fmt(center.y - radius)} ${fmt(center.x)} ${fmt(center.y - radius)} c`,
    `${fmt(center.x + radius * KAPPA)} ${fmt(center.y - radius)} ${fmt(center.x + radius)} ${fmt(center.y - radius * KAPPA)} ${fmt(center.x + radius)} ${fmt(center.y)} c`,
    "h",
  ].join(" ");
}

function addNavigoLogo(lines: string[], centerX: number, topY: number, width: number) {
  const scale = width / LOGO_CROP.w;
  const height = LOGO_CROP.h * scale;
  const originX = centerX - width / 2;
  const originY = topY - height;

  lines.push(`q ${rgb(WHITE)} rg ${rgb(WHITE)} RG 1 w`);
  LOGO_PATHS.forEach((path) => {
    lines.push(`${svgPathToPdfPath(path, originX, originY, scale)} f`);
  });
  LOGO_CIRCLES.forEach((circle) => {
    lines.push(`${circlePath(circle.cx, circle.cy, circle.r, originX, originY, scale)} S`);
  });
  lines.push("Q");
}

function addSphereMark(lines: string[], cx: number, cy: number, r: number) {
  const d = r * 0.66;
  const cos30 = 0.8660254;
  paintShading(lines, cx, cy - r * 0.28, r * 1.18, "Shadow", "GSH");
  addSphere(lines, cx - cos30 * d, cy - 0.5 * d, r);
  addSphere(lines, cx + cos30 * d, cy - 0.5 * d, r);
  addSphere(lines, cx, cy + d, r);
}

// ── Film grain ───────────────────────────────────────────────────────────
// A luminance-noise image, drawn full-bleed and blended with Soft Light. This
// is what gives the page its photographic, textured feel (matching the brand
// art). The noise is symmetric around mid-gray (128) so Soft Light only adds
// grain and never tints the underlying blue. Built once, reused on every page.
const GRAIN_W = 842; // 1:1 with page points → fine, non-chunky grain pitch
const GRAIN_H = 595;
const GRAIN_SPREAD = 38; // contrast of the noise around 128; higher = grittier

function buildGrainImage() {
  const pixels = Buffer.allocUnsafe(GRAIN_W * GRAIN_H);
  let seed = 0x9e3779b1 >>> 0;
  const rand = () => {
    seed = (seed * 1664525 + 1013904223) >>> 0;
    return seed / 4294967296;
  };
  for (let i = 0; i < pixels.length; i++) {
    // Triangular distribution (average of two uniforms) clusters values near
    // mid-gray, so the grain is soft and film-like rather than salt-and-pepper.
    const n = (rand() + rand()) * 0.5 - 0.5; // ~[-0.5, 0.5], peaked at 0
    const v = 128 + n * 2 * GRAIN_SPREAD;
    pixels[i] = v < 0 ? 0 : v > 255 ? 255 : Math.round(v);
  }
  return deflateSync(pixels);
}

const GRAIN_DATA = buildGrainImage();

// ── Background ─────────────────────────────────────────────────────────────

function addBackground(lines: string[]) {
  // Bright royal-blue base, not dark navy.
  fillRect(lines, 0, 0, PAGE_WIDTH, PAGE_HEIGHT, hex("#123DFF"));

  // Electric blue gradient sweep.
  lines.push(`q 0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT} re W n /BaseSweep sh Q`);

  // Dark purple/navy vignette at edges/corners.
  lines.push(`q /GDR gs 0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT} re W n /Edge sh Q`);
}

type PanelRect = { x: number; y: number; w: number; h: number; r: number };

// Film grain — a full-bleed luminance-noise image blended with Soft Light,
// clipped to the bare background so the texture never shows inside the glass
// panels. Drawn right after the background (before panels/text) so it stays
// under the header and the panels read clean.
function addGrain(lines: string[], panels: PanelRect[] = []) {
  lines.push("q");
  // Clip = whole page with each panel punched out via the even-odd rule.
  let clip = `0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT} re`;
  for (const p of panels) clip += ` ${roundedRectPath(p.x, p.y, p.w, p.h, p.r)}`;
  lines.push(`${clip} W* n`);
  lines.push(`/GGR gs ${PAGE_WIDTH} 0 0 ${PAGE_HEIGHT} 0 0 cm /Grain Do`);
  lines.push("Q");
}

// ═══════════════════════════════════════════════════════════════════════════
//  Layout — page 1 (cover) and page 2 (analytics)
// ═══════════════════════════════════════════════════════════════════════════

const MARGIN_X = 40;
const CONTENT_W = PAGE_WIDTH - MARGIN_X * 2;

const HEADER_LOGO_TOP_Y = 542;
const HEADER_LOGO_W = 138;
const HEADER_TITLE_Y = 452;

const CARD_GAP = 14;
const CARD_H = 76; // taller cards give more breathing room
const CARD_W = Math.floor((CONTENT_W - CARD_GAP * 3) / 4);
const CARD_TOP_Y = 420;
const CARD_BOTTOM_Y = CARD_TOP_Y - CARD_H; // = 344
const CARD_START_X = MARGIN_X;

const TABLE_GAP = 22;
const TABLE_W = Math.floor((CONTENT_W - TABLE_GAP) / 2);
const TABLE_BOTTOM_Y = 15;
const TABLE_X0 = MARGIN_X;
const TABLE_X1 = MARGIN_X + TABLE_W + TABLE_GAP;
const LONGEST_CARD_X = MARGIN_X;
const LONGEST_CARD_H = 236; // tall enough for 5 rows at generous spacing
const LONGEST_CARD_Y = CARD_BOTTOM_Y - 44 - LONGEST_CARD_H; // 44pt gap below cards
const LONGEST_CARD_W = CONTENT_W;
const SECOND_PAGE_TABLE_TOP_Y = 410;
const SECOND_PAGE_TABLE_BOTTOM_Y = 22;
const SECOND_PAGE_TABLE_H = SECOND_PAGE_TABLE_TOP_Y - SECOND_PAGE_TABLE_BOTTOM_Y;

function addMetricCard(lines: string[], index: number, title: string, value: string) {
  const x = CARD_START_X + index * (CARD_W + CARD_GAP);
  const y = CARD_BOTTOM_Y;
  const cx = x + CARD_W / 2;

  glassPanel(lines, x, y, CARD_W, CARD_H, 16);

  // Lowercase premium label near the top, spacious value below.
  emitCenteredText(lines, cx, y + CARD_H - 23, titleCase(title), {
    size: 10,
    font: "F2",
    fill: MUTED,
    tracking: 0.75,
  });
  emitCenteredText(lines, cx, y + 17, value, { size: 20, font: "F2", fill: WHITE });
}

type ReportSession = {
  duration: number;
  timestamp: string;
};

function parseSessionTimestamp(timestamp: string) {
  if (!timestamp) return null;
  const compact = timestamp.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/i);
  if (compact) {
    const [, year, month, day, hour, minute, second] = compact;
    return new Date(
      Date.UTC(Number(year), Number(month) - 1, Number(day), Number(hour), Number(minute), Number(second))
    );
  }

  const date = new Date(timestamp);
  return Number.isNaN(date.getTime()) ? null : date;
}

function formatSessionDate(timestamp: string) {
  if (!timestamp) return "--";
  const pad = (n: number) => String(n).padStart(2, "0");
  const date = parseSessionTimestamp(timestamp);
  if (!date) {
    const iso = timestamp.split(/[T\s]/)[0] || "";
    const m = iso.match(/^(\d{4})-(\d{2})-(\d{2})$/);
    return m ? `${m[3]}-${m[2]}-${m[1]}` : iso || "--";
  }
  return `${pad(date.getUTCDate())}-${pad(date.getUTCMonth() + 1)}-${date.getUTCFullYear()}`;
}

// 24h h/m/s → "1:10:30 PM"
function formatClock12(hours: number, minutes: number, seconds: number) {
  const period = hours < 12 ? "AM" : "PM";
  const hour12 = hours % 12 || 12;
  const pad = (n: number) => String(n).padStart(2, "0");
  return `${pad(hour12)}:${pad(minutes)}:${pad(seconds)} ${period}`;
}

function formatSessionTime(timestamp: string) {
  if (!timestamp) return "--";
  const date = parseSessionTimestamp(timestamp);
  if (date) {
    return formatClock12(date.getUTCHours(), date.getUTCMinutes(), date.getUTCSeconds());
  }
  const match = timestamp.match(/(?:T|\s)(\d{1,2}):(\d{2})(?::(\d{2}))?/);
  if (match) {
    return formatClock12(Number(match[1]), Number(match[2]), Number(match[3] ?? 0));
  }
  return "--";
}

function formatSessionEndTime(timestamp: string, duration: number) {
  if (!timestamp || duration <= 0) return "--";
  const date = parseSessionTimestamp(timestamp);
  if (date) {
    const ended = new Date(date.getTime() + Math.round(duration) * 1000);
    return formatClock12(ended.getUTCHours(), ended.getUTCMinutes(), ended.getUTCSeconds());
  }

  const match = timestamp.match(/(?:T|\s)(\d{1,2}):(\d{2})(?::(\d{2}))?/);
  if (!match) return "--";
  const startSeconds = Number(match[1]) * 3600 + Number(match[2]) * 60 + Number(match[3] ?? 0);
  const endSeconds = (startSeconds + Math.round(duration)) % 86_400;
  return formatClock12(Math.floor(endSeconds / 3600), Math.floor((endSeconds % 3600) / 60), endSeconds % 60);
}

function addLongestSessionCard(lines: string[], sessions: ReportSession[]) {
  const top = LONGEST_CARD_Y + LONGEST_CARD_H;
  const pad = 24;
  const left = LONGEST_CARD_X + pad;
  const right = LONGEST_CARD_X + LONGEST_CARD_W - pad;
  const colStarted = LONGEST_CARD_X + 246;
  const colEnded = LONGEST_CARD_X + 430;

  glassPanel(lines, LONGEST_CARD_X, LONGEST_CARD_Y, LONGEST_CARD_W, LONGEST_CARD_H, 20);

  // Section title — lowercase, muted, with generous tracking for the premium label feel.
  emitText(lines, left, top - 28, "Longest Session Duration", {
    size: 14,
    font: "F2",
    fill: MUTED,
    tracking: 0.75,
  });

  const headerOpts = { size: 9.4, font: "F2", fill: FAINT, tracking: 0.35 } as const;
  const headerY = top - 56;
  emitText(lines, left, headerY, "Date", headerOpts);
  emitText(lines, colStarted, headerY, "Started", headerOpts);
  emitText(lines, colEnded, headerY, "Ended", headerOpts);
  emitRightText(lines, right, headerY, "Duration", headerOpts);
  emitLine(lines, left, headerY - 10, right, headerY - 10, HAIRLINE, 0.5, "GHL");

  const rows = sessions.slice(0, 5);
  if (!rows.length) {
    emitText(lines, left, headerY - 36, "No session data for this range.", { size: 10, font: "F1", fill: WHITE });
    return;
  }

  const rowStep = 28;
  let rowY = headerY - 38;
  rows.forEach((session, index) => {
    if (index % 2 === 1) {
      const path = roundedRectPath(LONGEST_CARD_X + 12, rowY - 8, LONGEST_CARD_W - 24, 24, 6);
      lines.push(`q /GR gs ${rgb(ROW_HIGHLIGHT)} rg ${path} f Q`);
    }
    const rowOpts = { size: 11, font: "F1", fill: WHITE } as const;
    emitText(lines, left, rowY, formatSessionDate(session.timestamp), rowOpts);
    emitText(lines, colStarted, rowY, formatSessionTime(session.timestamp), rowOpts);
    emitText(lines, colEnded, rowY, formatSessionEndTime(session.timestamp, session.duration), rowOpts);
    emitRightText(lines, right, rowY, secondsLabel(session.duration), { size: 11, font: "F1", fill: WHITE });
    rowY -= rowStep;
  });
}

function truncate(value: string, max: number) {
  return value.length > max ? `${value.slice(0, Math.max(1, max - 1))}…` : value;
}

function addDataPanel(
  lines: string[],
  x: number,
  y: number,
  w: number,
  h: number,
  title: string,
  headers: [string, string],
  rows: [string, string][]
) {
  const pad = 20;
  const rowH = 30; // generous row height — fills the panel with no empty band below
  const labelSize = 11;
  const valueSize = 11;

  glassPanel(lines, x, y, w, h, 20);

  const titleBaseline = y + h - 32;
  emitCenteredText(lines, x + w / 2, titleBaseline, title, { size: 16, font: "F1", fill: WHITE });

  const headerBaseline = titleBaseline - 26;
  emitText(lines, x + pad, headerBaseline, titleCase(headers[0]), {
    size: 10,
    font: "F2",
    fill: FAINT,
    tracking: 0.6,
  });
  emitRightText(lines, x + w - pad, headerBaseline, titleCase(headers[1]), {
    size: 10,
    font: "F2",
    fill: FAINT,
    tracking: 0.6,
  });

  const dividerY = headerBaseline - 11;
  emitLine(lines, x + pad, dividerY, x + w - pad, dividerY, HAIRLINE, 0.5, "GHL");

  const valueSlot = 92;
  const labelMaxChars = Math.max(6, Math.floor((w - pad * 2 - valueSlot) / (labelSize * 0.5)));

  let rowY = dividerY - rowH + 4;
  rows.slice(0, 10).forEach(([label, value], i) => {
    if (i % 2 === 1) {
      const path = roundedRectPath(x + 8, rowY - 8, w - 16, rowH - 1, 6);
      lines.push(`q /GR gs ${rgb(ROW_HIGHLIGHT)} rg ${path} f Q`);
    }
    emitText(lines, x + pad, rowY, truncate(label, labelMaxChars), {
      size: labelSize,
      font: "F1",
      fill: WHITE,
    });
    emitRightText(lines, x + w - pad, rowY, value, { size: valueSize, font: "F2", fill: MUTED });
    rowY -= rowH;
  });

  if (!rows.length) {
    emitText(lines, x + pad, dividerY - rowH, "No data for this range.", {
      size: 9.5,
      font: "F2",
      fill: FAINT,
    });
  }
}

// ═══════════════════════════════════════════════════════════════════════════
//  PDF assembler
// ═══════════════════════════════════════════════════════════════════════════

function makePdf(pageStreams: string[]) {
  const regularFontObjNum = 3;
  const semiboldFontObjNum = 4;
  const regularDescriptorObjNum = 5;
  const semiboldDescriptorObjNum = 6;
  const regularFontFileObjNum = 7;
  const semiboldFontFileObjNum = 8;
  const grainObjNum = 9;
  const firstPageObj = 10;

  const fontObject = (fontName: string, metrics: TtfMetrics, descriptorObjNum: number) =>
    `<< /Type /Font /Subtype /TrueType /BaseFont /${fontName} /Encoding /WinAnsiEncoding ` +
    `/FirstChar 32 /LastChar 255 /Widths [${metrics.widths.join(" ")}] ` +
    `/FontDescriptor ${descriptorObjNum} 0 R >>`;
  const descriptorObject = (fontName: string, metrics: TtfMetrics, fontFileObjNum: number) =>
    `<< /Type /FontDescriptor /FontName /${fontName} /Flags 32 ` +
    `/FontBBox [${metrics.bbox.join(" ")}] /ItalicAngle 0 /Ascent ${metrics.ascent} ` +
    `/Descent ${metrics.descent} /CapHeight ${metrics.capHeight} /StemV 80 ` +
    `/FontFile2 ${fontFileObjNum} 0 R >>`;
  const fontFileObject = (font: Buffer) => [
    `<< /Length ${font.length} /Length1 ${font.length} >>\nstream\n`,
    font,
    "\nendstream",
  ];

  // Each object is a list of chunks (strings, or raw Buffers for binary stream
  // data such as the grain image).
  const objects: (string | Buffer)[][] = [
    ["<< /Type /Catalog /Pages 2 0 R >>"],
    [
      `<< /Type /Pages /Kids [${pageStreams
        .map((_, i) => `${firstPageObj + i * 2} 0 R`)
        .join(" ")}] /Count ${pageStreams.length} >>`,
    ],
    [fontObject("PublicSans-Regular", PUBLIC_SANS_REGULAR_METRICS, regularDescriptorObjNum)],
    [fontObject("PublicSans-SemiBold", PUBLIC_SANS_SEMIBOLD_METRICS, semiboldDescriptorObjNum)],
    [descriptorObject("PublicSans-Regular", PUBLIC_SANS_REGULAR_METRICS, regularFontFileObjNum)],
    [descriptorObject("PublicSans-SemiBold", PUBLIC_SANS_SEMIBOLD_METRICS, semiboldFontFileObjNum)],
    fontFileObject(PUBLIC_SANS_REGULAR),
    fontFileObject(PUBLIC_SANS_SEMIBOLD),
    [
      `<< /Type /XObject /Subtype /Image /Width ${GRAIN_W} /Height ${GRAIN_H} ` +
        `/ColorSpace /DeviceGray /BitsPerComponent 8 /Filter /FlateDecode /Length ${GRAIN_DATA.length} >>\nstream\n`,
      GRAIN_DATA,
      "\nendstream",
    ],
  ];

  pageStreams.forEach((stream, idx) => {
    const pageObjNum = firstPageObj + idx * 2;
    const streamObjNum = pageObjNum + 1;
    objects.push([
      `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${PAGE_WIDTH} ${PAGE_HEIGHT}] ` +
        `/Resources << /Font << /F1 ${regularFontObjNum} 0 R /F2 ${semiboldFontObjNum} 0 R >> /XObject << /Grain ${grainObjNum} 0 R >> ` +
        `/ExtGState ${PAGE_EXTGSTATE} /Shading ${PAGE_SHADINGS} >> ` +
        `/Contents ${streamObjNum} 0 R >>`,
    ]);
    objects.push([`<< /Length ${Buffer.byteLength(stream, "latin1")} >>\nstream\n${stream}\nendstream`]);
  });

  const chunks: Buffer[] = [];
  let length = 0;
  const append = (chunk: string | Buffer) => {
    const buf = typeof chunk === "string" ? Buffer.from(chunk, "latin1") : chunk;
    chunks.push(buf);
    length += buf.length;
  };

  append("%PDF-1.4\n");
  const offsets: number[] = [];
  objects.forEach((parts, i) => {
    offsets.push(length);
    append(`${i + 1} 0 obj\n`);
    parts.forEach(append);
    append("\nendobj\n");
  });

  const xrefOffset = length;
  let xref = `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`;
  offsets.forEach((o) => {
    xref += `${String(o).padStart(10, "0")} 00000 n \n`;
  });
  xref += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
  append(xref);

  return Buffer.concat(chunks);
}

// ═══════════════════════════════════════════════════════════════════════════
//  Report builder
// ═══════════════════════════════════════════════════════════════════════════

type ReportArgs = {
  title: string;
  startDate: string;
  endDate: string;
  data: Record<string, unknown>;
  report: Record<string, unknown> | null;
};

type ReportModel = {
  totalDuration: number;
  sessionCount: number;
  averageDuration: number;
  brochuresSent: number;
  topSessions: ReportSession[];
  topViews: ReportRow[];
  topFlats: ReportRow[];
};

function computeReportModel({ title, data, report }: ReportArgs): ReportModel {
  const sessionCount =
    pickMetric(data, report, "session_count") ||
    pickMetric(data, report, "total_sessions") ||
    pickMetric(data, report, "sessions_count");
  const totalDuration = normalizeAggregateDurationSeconds(
    pickMetric(data, report, "total_duration_sec") ||
      pickMetric(data, report, "total_duration") ||
      pickMetric(data, report, "total_duration_seconds"),
    sessionCount
  );
  const averageDuration = normalizeDurationSeconds(
    pickMetric(data, report, "average_duration_sec") ||
      pickMetric(data, report, "average_duration") ||
      pickMetric(data, report, "average_session_duration")
  );
  const brochuresSent = pickMetric(data, report, "brochures_sent");
  const topViews = getTopViewRows(data, report, title);
  const topFlats = getTopFlatRows(data, report, title);
  const topSessions = pickTopSessions(data, report, topViews);

  return {
    totalDuration,
    sessionCount,
    averageDuration,
    brochuresSent,
    topSessions,
    topViews,
    topFlats,
  };
}

function addReportHeader(lines: string[], title: string, startDate: string, endDate: string) {
  addNavigoLogo(lines, PAGE_CENTER_X, HEADER_LOGO_TOP_Y, HEADER_LOGO_W);
  emitCenteredText(lines, PAGE_CENTER_X, HEADER_TITLE_Y, reportTitle(title, startDate, endDate), {
    size: 15.5,
    font: "F1",
    fill: WHITE,
  });
}

function composeSummaryPage(model: ReportModel, title: string, startDate: string, endDate: string): string[] {
  const page: string[] = [];
  const panels: PanelRect[] = [
    ...[0, 1, 2, 3].map((i) => ({
      x: CARD_START_X + i * (CARD_W + CARD_GAP),
      y: CARD_BOTTOM_Y,
      w: CARD_W,
      h: CARD_H,
      r: 12,
    })),
    { x: LONGEST_CARD_X, y: LONGEST_CARD_Y, w: LONGEST_CARD_W, h: LONGEST_CARD_H, r: 16 },
  ];
  addBackground(page);
  addGrain(page, panels);
  addReportHeader(page, title, startDate, endDate);
  addMetricCard(page, 0, "Total Duration", secondsLabel(model.totalDuration));
  addMetricCard(page, 1, "Session Count", String(Math.round(model.sessionCount)));
  addMetricCard(page, 2, "Average Duration", secondsLabel(model.averageDuration));
  addMetricCard(page, 3, "Brochures Sent", String(Math.round(model.brochuresSent)));
  addLongestSessionCard(page, model.topSessions);
  return page;
}

function composeTablesPage(model: ReportModel, title: string, startDate: string, endDate: string): string[] {
  const page: string[] = [];
  const panels: PanelRect[] = [
    { x: TABLE_X0, y: SECOND_PAGE_TABLE_BOTTOM_Y, w: TABLE_W, h: SECOND_PAGE_TABLE_H, r: 16 },
    { x: TABLE_X1, y: SECOND_PAGE_TABLE_BOTTOM_Y, w: TABLE_W, h: SECOND_PAGE_TABLE_H, r: 16 },
  ];
  addBackground(page);
  addGrain(page, panels);
  addReportHeader(page, title, startDate, endDate);
  addDataPanel(
    page,
    TABLE_X0,
    SECOND_PAGE_TABLE_BOTTOM_Y,
    TABLE_W,
    SECOND_PAGE_TABLE_H,
    "Top 10 Views",
    ["View", "Time"],
    model.topViews.map(
      (r) => [r.detail ? `${r.label} - ${r.detail}` : r.label, secondsLabel(r.time)] as [string, string]
    )
  );

  addDataPanel(
    page,
    TABLE_X1,
    SECOND_PAGE_TABLE_BOTTOM_Y,
    TABLE_W,
    SECOND_PAGE_TABLE_H,
    "Top 10 Flat Views",
    ["Flat", "Time"],
    model.topFlats.map((r) => [flatNumberForRow(r), secondsLabel(r.time)] as [string, string])
  );

  return page;
}

export function buildReportPdf(args: ReportArgs) {
  const model = computeReportModel(args);
  return makePdf([
    makeContentStream(composeSummaryPage(model, args.title, args.startDate, args.endDate)),
    makeContentStream(composeTablesPage(model, args.title, args.startDate, args.endDate)),
  ]);
}

export function buildReportPdfPagesForTest(args: ReportArgs) {
  const model = computeReportModel(args);
  return {
    summary: makePdf([makeContentStream(composeSummaryPage(model, args.title, args.startDate, args.endDate))]),
    tables: makePdf([makeContentStream(composeTablesPage(model, args.title, args.startDate, args.endDate))]),
  };
}
Editor is loading...
Leave a Comment