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
}