feat(audit): add timeline helpers (groupByDay + relativeTime) (B/F4)

This commit is contained in:
diegosouzapw
2026-05-27 22:00:51 -03:00
parent b9e5d940a4
commit fb54bcd994

132
src/lib/audit/timeline.ts Normal file
View File

@@ -0,0 +1,132 @@
/**
* Timeline helpers — pure functions for grouping AuditLogEntry arrays by day
* and producing human-readable relative timestamps.
*
* No I/O, no side-effects — safe to import in both server and client contexts.
*/
import type { AuditLogEntry } from "@/lib/compliance/index";
export interface DayGroup {
/** YYYY-MM-DD in local server time */
dayKey: string;
/** "today" | "yesterday" | ISO date string for older days */
label: "today" | "yesterday" | string;
entries: AuditLogEntry[];
}
/**
* Returns YYYY-MM-DD for a given ISO timestamp (using local server time).
*/
function toDayKey(iso: string): string {
const d = new Date(iso);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Returns YYYY-MM-DD for a given epoch ms (local server time).
*/
function epochToDayKey(ms: number): string {
const d = new Date(ms);
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
/**
* Groups audit log entries by calendar day (local server time), sorted
* descending (most recent day first). Each group has a human label:
* - "today" for the current day
* - "yesterday" for the previous day
* - ISO date string "YYYY-MM-DD" for older days
*
* @param entries - Flat list of audit entries (order not assumed)
* @param referenceNowMs - Override for "now" (ms since epoch). Defaults to Date.now().
*/
export function groupByDay(entries: AuditLogEntry[], referenceNowMs?: number): DayGroup[] {
if (!entries.length) return [];
const nowMs = referenceNowMs ?? Date.now();
const todayKey = epochToDayKey(nowMs);
const yesterdayKey = epochToDayKey(nowMs - 24 * 60 * 60 * 1000);
// Sort descending by timestamp
const sorted = [...entries].sort((a, b) => {
const ta = a.timestamp ?? "";
const tb = b.timestamp ?? "";
if (ta > tb) return -1;
if (ta < tb) return 1;
// tiebreak by id desc
const ia = typeof a.id === "number" ? a.id : 0;
const ib = typeof b.id === "number" ? b.id : 0;
return ib - ia;
});
const groupMap = new Map<string, AuditLogEntry[]>();
const dayOrder: string[] = [];
for (const entry of sorted) {
const dk = toDayKey(entry.timestamp ?? "");
if (!groupMap.has(dk)) {
groupMap.set(dk, []);
dayOrder.push(dk);
}
groupMap.get(dk)!.push(entry);
}
return dayOrder.map((dk) => {
let label: string;
if (dk === todayKey) {
label = "today";
} else if (dk === yesterdayKey) {
label = "yesterday";
} else {
label = dk;
}
return { dayKey: dk, label, entries: groupMap.get(dk)! };
});
}
/**
* Returns a human-readable relative time string for the given ISO timestamp.
*
* @param iso - ISO 8601 timestamp string
* @param locale - "en" or "pt-BR"
* @param referenceNowMs - Override for "now" (ms since epoch). Defaults to Date.now().
*/
export function relativeTime(
iso: string,
locale: "en" | "pt-BR",
referenceNowMs?: number
): string {
const nowMs = referenceNowMs ?? Date.now();
const then = new Date(iso).getTime();
if (!Number.isFinite(then)) {
return locale === "pt-BR" ? "agora há pouco" : "just now";
}
const diffMs = nowMs - then;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHour = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHour / 24);
if (locale === "pt-BR") {
if (diffSec < 60) return "agora há pouco";
if (diffMin < 60) return `${diffMin} min`;
if (diffHour < 24) return `${diffHour} h`;
if (diffDay === 1) return "ontem";
return `${diffDay} dias`;
}
// English
if (diffSec < 60) return "just now";
if (diffMin < 60) return `${diffMin} min ago`;
if (diffHour < 24) return `${diffHour} h ago`;
if (diffDay === 1) return "yesterday";
return `${diffDay} days ago`;
}