Violation report: clean up Activity History formatting

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 22:48:18 -04:00
parent feb0d28c25
commit bebd5bd7cb
2 changed files with 74 additions and 13 deletions
+25 -1
View File
@@ -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;
+49 -12
View File
@@ -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<string, string> = {
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",