From bebd5bd7cbd817e877806c59e946a3b1b3eafc8d Mon Sep 17 00:00:00 2001 From: renee-png Date: Wed, 17 Jun 2026 22:48:18 -0400 Subject: [PATCH] Violation report: clean up Activity History formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Violation Updated" entries rendered raw change diffs: a "→" arrow the PDF font can't draw (showed as "!'"), snake_case field names, "—" for empty, and a dense single-paragraph wall. - Render-time (fixes existing reports): normalizeTimelineText now maps "→"→"to" and smart quotes/dashes/ellipsis to ASCII, prettifies snake_case field labels (violation_type → "Violation Type", etc.), and puts each change on its own line for proper spacing. - Logger (future entries): logViolationUpdated/logBulkUpdate now write professional notes — "Description / Notes changed from "X" to "Y".", "… set to …", "… cleared." — with Title-Case labels, long values truncated to 80 chars, and one change per line. No arrows or raw field names. Co-Authored-By: Claude Opus 4.8 --- src/lib/violationPdfGenerator.js | 26 ++++++++++++- src/lib/violationTimelineLogger.ts | 61 ++++++++++++++++++++++++------ 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/src/lib/violationPdfGenerator.js b/src/lib/violationPdfGenerator.js index 9cdab7b..7eae56e 100644 --- a/src/lib/violationPdfGenerator.js +++ b/src/lib/violationPdfGenerator.js @@ -45,8 +45,32 @@ const safeStr = (val) => { return cleanText(String(val)); }; +// Field-name → human label for change logs stored as snake_case. +const TIMELINE_FIELD_LABELS = { + violation_type: 'Violation Type', article_section: 'Article / Section', + requested_action: 'Description / Notes', citation: 'Citation', address: 'Address', + due_date: 'Due Date', violation_date: 'Violation Date', status: 'Status', + stage: 'Notice Level', notice_level: 'Notice Level', priority: 'Priority', + notes: 'Notes', assigned_to: 'Assigned To', title: 'Title', +}; + const normalizeTimelineText = (val) => { - const text = safeStr(val); + let text = safeStr(val); + // Map glyphs the PDF font (Helvetica/WinAnsi) can't render to ASCII so they + // don't show as artifacts (e.g. the "→" arrow rendering as "!'"). + text = text + .replace(/\s*→\s*/g, ' to ') // → arrow + .replace(/[‘’′]/g, "'") // ' ' ′ smart single quotes + .replace(/[“”″]/g, '"') // " " ″ smart double quotes + .replace(/–/g, '-') // – en dash + .replace(/…/g, '...'); // … ellipsis + // Prettify snake_case field labels left in older change logs. + for (const [key, label] of Object.entries(TIMELINE_FIELD_LABELS)) { + text = text.replace(new RegExp(`(^|[\\s;(])${key}:`, 'g'), `$1${label}:`); + } + // Put each change on its own line for readable paragraph spacing. + text = text.replace(/;\s+/g, '\n'); + // Repair stray letter-spacing artifacts on a per-line basis. return text.split('\n').map((line) => { const singleLetterMatches = line.match(/(?:^|\s)[A-Za-z](?=\s)/g) || []; if (singleLetterMatches.length < 8) return line; diff --git a/src/lib/violationTimelineLogger.ts b/src/lib/violationTimelineLogger.ts index b166383..32a848a 100644 --- a/src/lib/violationTimelineLogger.ts +++ b/src/lib/violationTimelineLogger.ts @@ -11,6 +11,37 @@ import { v4 as uuidv4 } from "uuid"; // ── Helpers ───────────────────────────────────────────────────────── +// Human-readable labels for change logs (avoids raw snake_case in reports). +const FIELD_LABELS: Record = { + violation_type: "Violation Type", + article_section: "Article / Section", + requested_action: "Description / Notes", + citation: "Citation", + address: "Address", + due_date: "Due Date", + violation_date: "Violation Date", + status: "Status", + stage: "Notice Level", + notice_level: "Notice Level", + priority: "Priority", + notes: "Notes", + assigned_to: "Assigned To", + title: "Title", +}; + +const fieldLabel = (field: string): string => + FIELD_LABELS[field] ?? + field.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); + +// Trim a value for an audit note: collapse whitespace, cap long text so a long +// citation doesn't become a wall of text. Empty/none -> "". +const fmtChangeValue = (v: unknown): string => { + if (v === null || v === undefined) return ""; + const s = String(v).replace(/\s+/g, " ").trim(); + if (!s || s === "—") return ""; + return s.length > 80 ? `${s.slice(0, 80).trimEnd()}...` : s; +}; + const estNow = () => new Date().toLocaleString("en-US", { timeZone: "America/New_York" }); @@ -254,14 +285,14 @@ export async function logBulkUpdate( const userId = await currentUserId(); const fieldDescriptions: string[] = []; - if (changes.status) fieldDescriptions.push(`status → "${changes.status}"`); - if (changes.stage) fieldDescriptions.push(`stage → "${changes.stage}"`); - if (changes.priority) fieldDescriptions.push(`priority → "${changes.priority}"`); - if (changes.due_date) fieldDescriptions.push(`due date → ${changes.due_date}`); - if (changes.violation_date) fieldDescriptions.push(`violation date → ${changes.violation_date}`); - if (changes.notes) fieldDescriptions.push("notes updated"); + if (changes.status) fieldDescriptions.push(`Status set to "${changes.status}"`); + if (changes.stage) fieldDescriptions.push(`Notice Level set to "${changes.stage}"`); + if (changes.priority) fieldDescriptions.push(`Priority set to "${changes.priority}"`); + if (changes.due_date) fieldDescriptions.push(`Due Date set to ${changes.due_date}`); + if (changes.violation_date) fieldDescriptions.push(`Violation Date set to ${changes.violation_date}`); + if (changes.notes) fieldDescriptions.push("Notes updated"); - const details = `Bulk update by ${userName}: ${fieldDescriptions.join(", ")}.`; + const details = `Bulk update by ${userName}. ${fieldDescriptions.join("; ")}.`; await appendTimelineEntry(violationId, { action: "Bulk Update Applied", @@ -285,12 +316,18 @@ export async function logViolationUpdated( const userName = await currentUserName(); const userId = await currentUserId(); - const fieldDescriptions = Object.entries(changes).map( - ([field, { from, to }]) => - `${field}: "${from || "—"}" → "${to || "—"}"` - ); + const fieldDescriptions = Object.entries(changes).map(([field, { from, to }]) => { + const label = fieldLabel(field); + const f = fmtChangeValue(from); + const t = fmtChangeValue(to); + if (!f && !t) return `${label} updated.`; + if (!f) return `${label} set to "${t}".`; + if (!t) return `${label} cleared.`; + return `${label} changed from "${f}" to "${t}".`; + }); - const details = `Violation updated by ${userName}. Changes: ${fieldDescriptions.join("; ")}.`; + // One change per line for readable paragraph spacing in the PDF/report. + const details = `Violation updated by ${userName}.\n${fieldDescriptions.join("\n")}`; await appendTimelineEntry(violationId, { action: "Violation Updated",