mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
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:
@@ -45,8 +45,32 @@ const safeStr = (val) => {
|
|||||||
return cleanText(String(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 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) => {
|
return text.split('\n').map((line) => {
|
||||||
const singleLetterMatches = line.match(/(?:^|\s)[A-Za-z](?=\s)/g) || [];
|
const singleLetterMatches = line.match(/(?:^|\s)[A-Za-z](?=\s)/g) || [];
|
||||||
if (singleLetterMatches.length < 8) return line;
|
if (singleLetterMatches.length < 8) return line;
|
||||||
|
|||||||
@@ -11,6 +11,37 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
|
|
||||||
// ── Helpers ─────────────────────────────────────────────────────────
|
// ── 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 = () =>
|
const estNow = () =>
|
||||||
new Date().toLocaleString("en-US", { timeZone: "America/New_York" });
|
new Date().toLocaleString("en-US", { timeZone: "America/New_York" });
|
||||||
|
|
||||||
@@ -254,14 +285,14 @@ export async function logBulkUpdate(
|
|||||||
const userId = await currentUserId();
|
const userId = await currentUserId();
|
||||||
|
|
||||||
const fieldDescriptions: string[] = [];
|
const fieldDescriptions: string[] = [];
|
||||||
if (changes.status) fieldDescriptions.push(`status → "${changes.status}"`);
|
if (changes.status) fieldDescriptions.push(`Status set to "${changes.status}"`);
|
||||||
if (changes.stage) fieldDescriptions.push(`stage → "${changes.stage}"`);
|
if (changes.stage) fieldDescriptions.push(`Notice Level set to "${changes.stage}"`);
|
||||||
if (changes.priority) fieldDescriptions.push(`priority → "${changes.priority}"`);
|
if (changes.priority) fieldDescriptions.push(`Priority set to "${changes.priority}"`);
|
||||||
if (changes.due_date) fieldDescriptions.push(`due date → ${changes.due_date}`);
|
if (changes.due_date) fieldDescriptions.push(`Due Date set to ${changes.due_date}`);
|
||||||
if (changes.violation_date) fieldDescriptions.push(`violation date → ${changes.violation_date}`);
|
if (changes.violation_date) fieldDescriptions.push(`Violation Date set to ${changes.violation_date}`);
|
||||||
if (changes.notes) fieldDescriptions.push("notes updated");
|
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, {
|
await appendTimelineEntry(violationId, {
|
||||||
action: "Bulk Update Applied",
|
action: "Bulk Update Applied",
|
||||||
@@ -285,12 +316,18 @@ export async function logViolationUpdated(
|
|||||||
const userName = await currentUserName();
|
const userName = await currentUserName();
|
||||||
const userId = await currentUserId();
|
const userId = await currentUserId();
|
||||||
|
|
||||||
const fieldDescriptions = Object.entries(changes).map(
|
const fieldDescriptions = Object.entries(changes).map(([field, { from, to }]) => {
|
||||||
([field, { from, to }]) =>
|
const label = fieldLabel(field);
|
||||||
`${field}: "${from || "—"}" → "${to || "—"}"`
|
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, {
|
await appendTimelineEntry(violationId, {
|
||||||
action: "Violation Updated",
|
action: "Violation Updated",
|
||||||
|
|||||||
Reference in New Issue
Block a user