Untitled
unknown
plain_text
a month ago
58 kB
4
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