From c9ffbaa62f0f8c92c820cea9da8612add8aefc67 Mon Sep 17 00:00:00 2001 From: Melvin PETIT Date: Fri, 12 Jun 2026 15:28:19 +0200 Subject: [PATCH] feat(reports): period-over-period deltas and span-flow layout Add comparison deltas (newly exposed employees, breaches with new detections, new alerts) computed over filter-derived windows: an explicit date range compares against the equal-length prior window, otherwise the trailing 30 days against the 30 days before. Surfaced as trend pills on the Exposure and Compliance stat cards, as automatic trend findings, and in the exposure CSV. StatCard gains an optional, non-breaking delta prop. Rework the customizable layout to a discrete-width flow grid: sections pick 1/3, 1/2 or full width and reorder via drag (dnd-kit). Rows stretch and use dense auto-flow so partial-width tiles fill empty space instead of leaving gaps; a DragOverlay keeps the dragged section at its own width. --- src/app/(dashboard)/reports/page.tsx | 4 +- src/components/dashboard/StatCard.tsx | 33 +++++- src/components/reports/ComplianceSection.tsx | 12 ++- src/components/reports/ExposureSection.tsx | 12 ++- src/components/reports/ReportCanvas.tsx | 9 +- src/components/reports/ReportSection.tsx | 2 +- src/lib/reports/csv.ts | 11 +- src/lib/reports/deltas.ts | 100 +++++++++++++++++++ src/lib/reports/findings.ts | 30 +++++- src/lib/reports/index.ts | 7 +- src/lib/reports/types.ts | 13 +++ 11 files changed, 215 insertions(+), 18 deletions(-) create mode 100644 src/lib/reports/deltas.ts diff --git a/src/app/(dashboard)/reports/page.tsx b/src/app/(dashboard)/reports/page.tsx index d4cc9d1..7334d15 100644 --- a/src/app/(dashboard)/reports/page.tsx +++ b/src/app/(dashboard)/reports/page.tsx @@ -45,12 +45,12 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom const sections: ReportSectionEntry[] = [ { id: "findings", title: "Key Findings", defaultSpan: 12, content: }, - { id: "exposure", title: "Exposure", defaultSpan: 12, content: }, + { id: "exposure", title: "Exposure", defaultSpan: 12, content: }, { id: "datatypes", title: "Data Types", defaultSpan: 6, content: }, { id: "departments", title: "Departments", defaultSpan: 6, content: }, { id: "trends", title: "Trends", defaultSpan: 12, content: }, { id: "employees", title: "Employees", defaultSpan: 12, content: }, - { id: "compliance", title: "Compliance", defaultSpan: 12, content: }, + { id: "compliance", title: "Compliance", defaultSpan: 12, content: }, ] return ( diff --git a/src/components/dashboard/StatCard.tsx b/src/components/dashboard/StatCard.tsx index 483fefb..59587bd 100644 --- a/src/components/dashboard/StatCard.tsx +++ b/src/components/dashboard/StatCard.tsx @@ -1,5 +1,12 @@ import { cn } from "@/lib/utils" -import type { LucideIcon } from "lucide-react" +import { ArrowUp, ArrowDown, Minus, type LucideIcon } from "lucide-react" + +export type StatDelta = { + value: number + label?: string + // Which direction is "good" (green). Security metrics usually want "down". + goodWhen?: "up" | "down" +} interface StatCardProps { label: string @@ -7,6 +14,7 @@ interface StatCardProps { description?: string icon: LucideIcon variant?: "default" | "critical" | "high" | "medium" | "ok" + delta?: StatDelta } const variants = { @@ -43,6 +51,7 @@ export function StatCard({ description, icon: Icon, variant = "default", + delta, }: StatCardProps) { const v = variants[variant] @@ -57,6 +66,7 @@ export function StatCard({ {description && (

{description}

)} + {delta && }
@@ -65,3 +75,24 @@ export function StatCard({
) } + +function DeltaPill({ delta }: { delta: StatDelta }) { + const { value, label, goodWhen = "up" } = delta + const Icon = value > 0 ? ArrowUp : value < 0 ? ArrowDown : Minus + const direction = value > 0 ? "up" : value < 0 ? "down" : "flat" + const tone = + direction === "flat" + ? "text-muted-foreground" + : direction === goodWhen + ? "text-severity-ok" + : "text-severity-high" + + return ( +

+ + {value > 0 ? "+" : ""} + {value} + {label && {label}} +

+ ) +} diff --git a/src/components/reports/ComplianceSection.tsx b/src/components/reports/ComplianceSection.tsx index 3e4ca72..6c055c5 100644 --- a/src/components/reports/ComplianceSection.tsx +++ b/src/components/reports/ComplianceSection.tsx @@ -1,9 +1,9 @@ import { ShieldCheck, BellRing, CheckCircle2, AlertTriangle } from "lucide-react" import { StatCard } from "@/components/dashboard/StatCard" import { ReportSection } from "./ReportSection" -import type { ComplianceSummary } from "@/lib/reports/types" +import type { ComplianceSummary, ReportDeltas } from "@/lib/reports/types" -export function ComplianceSection({ data }: { data: ComplianceSummary }) { +export function ComplianceSection({ data, deltas }: { data: ComplianceSummary; deltas: ReportDeltas }) { return ( - + + - - {/* Flow grid (screen) */} + {/* Flow grid (screen). Rows stretch and dense flow backfills holes, so 1/3 and + 1/2 tiles fill empty space instead of leaving gaps. */} setActiveId(null)} > -
+
{visibleOrder.map((id) => { const section = byId.get(id) if (!section) return null diff --git a/src/components/reports/ReportSection.tsx b/src/components/reports/ReportSection.tsx index 9f3c280..4d163cc 100644 --- a/src/components/reports/ReportSection.tsx +++ b/src/components/reports/ReportSection.tsx @@ -8,7 +8,7 @@ interface ReportSectionProps { export function ReportSection({ title, description, children }: ReportSectionProps) { return ( -
+

{title}

{description &&

{description}

} diff --git a/src/lib/reports/csv.ts b/src/lib/reports/csv.ts index bd6d674..4c52d19 100644 --- a/src/lib/reports/csv.ts +++ b/src/lib/reports/csv.ts @@ -38,7 +38,16 @@ function exposureCsv(d: ReportData): string { ["breach", "source", "breach date", "affected employees"], e.topBreaches.map((b) => [b.name, b.source, b.breachDate, b.affectedEmployees]) ) - return `${summary}\n\nTop breaches\n${breaches}` + const dl = d.deltas + const deltas = toCsv( + ["change", "current", "previous"], + [ + ["Newly exposed employees", dl.newlyExposed.current, dl.newlyExposed.previous], + ["Breaches with new detections", dl.newBreaches.current, dl.newBreaches.previous], + ["New alerts", dl.newAlerts.current, dl.newAlerts.previous], + ] + ) + return `${summary}\n\nTop breaches\n${breaches}\n\nPeriod change (${dl.windowLabel})\n${deltas}` } function dataTypesCsv(d: ReportData): string { diff --git a/src/lib/reports/deltas.ts b/src/lib/reports/deltas.ts new file mode 100644 index 0000000..49fa2d3 --- /dev/null +++ b/src/lib/reports/deltas.ts @@ -0,0 +1,100 @@ +import { prisma } from "@/lib/prisma" +import { employeeWhere, NO_DEPARTMENT, type ReportFilters } from "./filters" +import type { Delta, ReportDeltas } from "./types" + +const DAY = 24 * 60 * 60 * 1000 + +type Window = { curStart: Date; curEnd: Date; prevStart: Date; prevEnd: Date; label: string } + +// Comparison windows derived from the active filters. With an explicit date range +// we compare it against the equal-length window immediately before it; otherwise we +// compare the trailing 30 days against the 30 days before that. +function windows(f: ReportFilters): Window { + if (f.from && f.to) { + const curStart = new Date(`${f.from}T00:00:00.000`) + const curEnd = new Date(`${f.to}T23:59:59.999`) + const len = curEnd.getTime() - curStart.getTime() + return { + curStart, + curEnd, + prevStart: new Date(curStart.getTime() - len), + prevEnd: curStart, + label: "vs previous period", + } + } + const now = new Date() + const curStart = new Date(now.getTime() - 30 * DAY) + return { + curStart, + curEnd: now, + prevStart: new Date(now.getTime() - 60 * DAY), + prevEnd: curStart, + label: "in last 30 days", + } +} + +function recordInWindow(f: ReportFilters, start: Date, end: Date) { + return { + detectedAt: { gte: start, lt: end }, + ...(f.dataType && { exposedData: { has: f.dataType } }), + } +} + +function alertDept(f: ReportFilters) { + return f.department + ? { employee: { department: f.department === NO_DEPARTMENT ? null : f.department } } + : {} +} + +async function newlyExposed(companyId: string, f: ReportFilters, start: Date, end: Date): Promise { + return prisma.employee.count({ + where: { + ...employeeWhere(companyId, f), + breachRecords: { some: recordInWindow(f, start, end) }, + // First-time exposure: no breach record (of the same data-type scope) before the window. + NOT: { + breachRecords: { + some: { detectedAt: { lt: start }, ...(f.dataType && { exposedData: { has: f.dataType } }) }, + }, + }, + }, + }) +} + +function newBreaches(companyId: string, f: ReportFilters, start: Date, end: Date): Promise { + return prisma.breach.count({ + where: { records: { some: { employee: employeeWhere(companyId, f), ...recordInWindow(f, start, end) } } }, + }) +} + +function newAlerts(companyId: string, f: ReportFilters, start: Date, end: Date): Promise { + return prisma.alert.count({ + where: { companyId, createdAt: { gte: start, lt: end }, ...alertDept(f) }, + }) +} + +export async function getDeltas(companyId: string, f: ReportFilters): Promise { + const w = windows(f) + + const [ + exposedCur, exposedPrev, + breachesCur, breachesPrev, + alertsCur, alertsPrev, + ] = await Promise.all([ + newlyExposed(companyId, f, w.curStart, w.curEnd), + newlyExposed(companyId, f, w.prevStart, w.prevEnd), + newBreaches(companyId, f, w.curStart, w.curEnd), + newBreaches(companyId, f, w.prevStart, w.prevEnd), + newAlerts(companyId, f, w.curStart, w.curEnd), + newAlerts(companyId, f, w.prevStart, w.prevEnd), + ]) + + const delta = (current: number, previous: number): Delta => ({ current, previous }) + + return { + windowLabel: w.label, + newlyExposed: delta(exposedCur, exposedPrev), + newBreaches: delta(breachesCur, breachesPrev), + newAlerts: delta(alertsCur, alertsPrev), + } +} diff --git a/src/lib/reports/findings.ts b/src/lib/reports/findings.ts index 2776f7e..1d2c3f3 100644 --- a/src/lib/reports/findings.ts +++ b/src/lib/reports/findings.ts @@ -4,6 +4,7 @@ import type { DataTypeExposure, ExposureSummary, Finding, + ReportDeltas, } from "./types" const ORDER: Record = { @@ -85,14 +86,41 @@ function dataFindings(types: DataTypeExposure[]): Finding[] { return out } +function trendWord(current: number, previous: number): string { + if (current > previous) return `up from ${previous}` + if (current < previous) return `down from ${previous}` + return `unchanged from ${previous}` +} + +function deltaFindings(d: ReportDeltas): Finding[] { + const out: Finding[] = [] + const { newlyExposed, newBreaches } = d + + if (newlyExposed.current > 0) + out.push({ + severity: newlyExposed.current > newlyExposed.previous ? "high" : "medium", + message: `${plural(newlyExposed.current, "employee")} newly exposed in the latest period (${trendWord(newlyExposed.current, newlyExposed.previous)}).`, + }) + + if (newBreaches.current > 0) + out.push({ + severity: newBreaches.current > newBreaches.previous ? "medium" : "info", + message: `${plural(newBreaches.current, "breach")} with new detections in the latest period (${trendWord(newBreaches.current, newBreaches.previous)}).`, + }) + + return out +} + export function buildFindings( exposure: ExposureSummary, compliance: ComplianceSummary, - dataTypes: DataTypeExposure[] + dataTypes: DataTypeExposure[], + deltas: ReportDeltas ): Finding[] { return [ ...alertFindings(compliance), ...exposureFindings(exposure), ...dataFindings(dataTypes), + ...deltaFindings(deltas), ].sort((a, b) => ORDER[a.severity] - ORDER[b.severity]) } diff --git a/src/lib/reports/index.ts b/src/lib/reports/index.ts index 4d9675a..c06b7ce 100644 --- a/src/lib/reports/index.ts +++ b/src/lib/reports/index.ts @@ -5,6 +5,7 @@ import { getEmployeeBreakdown } from "./by-employee" import { getTrends } from "./trends" import { getCompliance } from "./compliance" import { buildFindings } from "./findings" +import { getDeltas } from "./deltas" import { EMPTY_FILTERS, type ReportFilters } from "./filters" import type { ReportData } from "./types" @@ -15,7 +16,7 @@ export async function getReportData( companyId: string, filters: ReportFilters = EMPTY_FILTERS, ): Promise { - const [exposure, dataTypes, departments, employees, trends, compliance] = + const [exposure, dataTypes, departments, employees, trends, compliance, deltas] = await Promise.all([ getExposureSummary(companyId, filters), getDataTypeExposure(companyId, filters), @@ -23,16 +24,18 @@ export async function getReportData( getEmployeeBreakdown(companyId, filters), getTrends(companyId, filters), getCompliance(companyId, filters), + getDeltas(companyId, filters), ]) return { generatedAt: new Date().toISOString(), - findings: buildFindings(exposure, compliance, dataTypes), + findings: buildFindings(exposure, compliance, dataTypes, deltas), exposure, dataTypes, departments, employees, trends, compliance, + deltas, } } diff --git a/src/lib/reports/types.ts b/src/lib/reports/types.ts index 602a155..b0600d4 100644 --- a/src/lib/reports/types.ts +++ b/src/lib/reports/types.ts @@ -80,6 +80,18 @@ export type Finding = { message: string } +export type Delta = { + current: number + previous: number +} + +export type ReportDeltas = { + windowLabel: string + newlyExposed: Delta + newBreaches: Delta + newAlerts: Delta +} + export type ReportData = { generatedAt: string findings: Finding[] @@ -89,4 +101,5 @@ export type ReportData = { employees: EmployeeReportRow[] trends: Trends compliance: ComplianceSummary + deltas: ReportDeltas }