feat(dashboard): add upcoming EOL + admin operations widgets
CI / Lint · Typecheck · Test · Build (push) Successful in 47s
CI / Playwright (smoke) (push) Has been skipped
CI / Build & push images (push) Successful in 1m5s

Surface operational signal alongside inventory: upcoming-EOL banner and
KPI for everyone; admin-only repairs tempo, FM close time, open FMs by
host, and custody backlog. Service shapes payload by role.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 16:22:10 -04:00
parent ae65d9f2a8
commit 52e092502b
5 changed files with 593 additions and 82 deletions
+3 -2
View File
@@ -2,9 +2,10 @@ import type { NextFunction, Request, Response } from 'express';
import { prisma } from '@vector/db';
import * as svc from '../services/analytics.js';
export async function dashboard(_req: Request, res: Response, next: NextFunction) {
export async function dashboard(req: Request, res: Response, next: NextFunction) {
try {
const data = await prisma.$transaction((tx) => svc.dashboard(tx));
const isAdmin = req.user?.role === 'ADMIN';
const data = await prisma.$transaction((tx) => svc.dashboard(tx, { isAdmin }));
res.json(data);
} catch (err) {
next(err);
+206 -36
View File
@@ -2,9 +2,15 @@ import { describe, expect, it } from 'vitest';
import type { Tx } from './types.js';
import { dashboard } from './analytics.js';
// Minimal in-memory tx double exercising the dashboard() aggregator.
// We only stub the calls dashboard() actually makes; other Prisma methods remain unimplemented.
function makeTx(args: {
type EolPartModel = {
id: string;
mpn: string;
eolDate: Date | null;
manufacturerId: string;
manufacturer: { name: string };
};
type FakeArgs = {
partCount: number;
stateRows: { state: string; count: number; totalPrice: number }[];
parts: {
@@ -15,57 +21,110 @@ function makeTx(args: {
partModelId: string;
}[];
openFms: number;
eolPartModels: {
id: string;
mpn: string;
eolDate: Date | null;
manufacturerId: string;
manufacturer: { name: string };
}[];
pastEolModels: EolPartModel[];
upcomingEolModels: EolPartModel[];
bins: { id: string; name: string; room: { name: string; site: { name: string } } }[];
}): Tx {
// Admin-only inputs. Ignored when isAdmin=false path is exercised.
repairs?: { performedAt: Date }[];
fmsClosed?: { openedAt: Date; closedAt: Date | null }[];
newFms7d?: number;
openFmGroups?: { hostId: string; count: number }[];
custodyGroups?: { custodianId: string | null; count: number }[];
hosts?: { id: string; name: string }[];
users?: { id: string; username: string }[];
};
function makeTx(args: FakeArgs): Tx {
const tx = {
part: {
count: async () => args.partCount,
groupBy: async () =>
args.stateRows.map((s) => ({
groupBy: async (q: { by: string[]; where?: { custodianId?: unknown } }) => {
if (q.by.includes('custodianId')) {
return (args.custodyGroups ?? []).map((g) => ({
custodianId: g.custodianId,
_count: { _all: g.count },
}));
}
return args.stateRows.map((s) => ({
state: s.state,
_count: { _all: s.count },
_sum: { price: s.totalPrice },
})),
}));
},
findMany: async () => args.parts,
},
fm: {
count: async () => args.openFms,
count: async (q: { where?: { status?: string; openedAt?: { gte: Date } } }) => {
if (q.where?.openedAt) return args.newFms7d ?? 0;
return args.openFms;
},
findMany: async () => args.fmsClosed ?? [],
groupBy: async () =>
(args.openFmGroups ?? []).map((g) => ({
hostId: g.hostId,
_count: { _all: g.count },
})),
},
partModel: {
findMany: async () => args.eolPartModels,
findMany: async (q: { where?: { eolDate?: { gt?: Date; lte?: Date; not?: unknown } } }) => {
const gt = q.where?.eolDate?.gt;
if (gt !== undefined) return args.upcomingEolModels;
return args.pastEolModels;
},
},
bin: {
findMany: async () => args.bins,
},
repair: {
count: async (q: { where?: { performedAt?: { gte: Date } } }) => {
const gte = q.where?.performedAt?.gte;
if (!gte) return 0;
return (args.repairs ?? []).filter((r) => r.performedAt >= gte).length;
},
findMany: async (q: { where?: { performedAt?: { gte: Date } } }) => {
const gte = q.where?.performedAt?.gte;
if (!gte) return args.repairs ?? [];
return (args.repairs ?? []).filter((r) => r.performedAt >= gte);
},
},
host: {
findMany: async () => args.hosts ?? [],
},
user: {
findMany: async () => args.users ?? [],
},
};
return tx as unknown as Tx;
}
const now = new Date('2026-04-16T00:00:00.000Z');
const daysAgo = (n: number) => new Date(now.getTime() - n * 24 * 60 * 60 * 1000);
const daysAhead = (n: number) => new Date(now.getTime() + n * 24 * 60 * 60 * 1000);
const HOUR_MS = 60 * 60 * 1000;
describe('analytics.dashboard', () => {
const EMPTY: FakeArgs = {
partCount: 0,
stateRows: [],
parts: [],
openFms: 0,
pastEolModels: [],
upcomingEolModels: [],
bins: [],
};
describe('analytics.dashboard — base fields', () => {
it('aggregates totals, state counts and open FMs', async () => {
const tx = makeTx({
...EMPTY,
partCount: 5,
stateRows: [
{ state: 'SPARE', count: 3, totalPrice: 1500 },
{ state: 'DEPLOYED', count: 2, totalPrice: 8000 },
],
parts: [],
openFms: 4,
eolPartModels: [],
bins: [],
});
const r = await dashboard(tx);
const r = await dashboard(tx, { isAdmin: false });
expect(r.totalParts).toBe(5);
expect(r.openFms).toBe(4);
expect(r.byState).toEqual([
@@ -76,48 +135,42 @@ describe('analytics.dashboard', () => {
it('buckets parts by age correctly', async () => {
const tx = makeTx({
...EMPTY,
partCount: 4,
stateRows: [],
parts: [
{ id: 'p1', state: 'SPARE', binId: null, createdAt: daysAgo(10), partModelId: 'pm' },
{ id: 'p2', state: 'SPARE', binId: null, createdAt: daysAgo(60), partModelId: 'pm' },
{ id: 'p3', state: 'SPARE', binId: null, createdAt: daysAgo(400), partModelId: 'pm' },
{ id: 'p4', state: 'SPARE', binId: null, createdAt: daysAgo(900), partModelId: 'pm' },
],
openFms: 0,
eolPartModels: [],
bins: [],
});
const r = await dashboard(tx);
const r = await dashboard(tx, { isAdmin: false });
const byLabel = Object.fromEntries(r.ageBuckets.map((b) => [b.label, b.count]));
expect(byLabel['030d']).toBe(1);
expect(byLabel['3190d']).toBe(1);
expect(byLabel['12y']).toBe(1);
expect(byLabel['2y+']).toBe(1);
// totals should match
expect(r.ageBuckets.reduce((s, b) => s + b.count, 0)).toBe(4);
});
it('ranks top bins and labels them site/room/bin', async () => {
const tx = makeTx({
...EMPTY,
partCount: 4,
stateRows: [],
parts: [
{ id: '1', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' },
{ id: '2', state: 'SPARE', binId: 'b1', createdAt: daysAgo(1), partModelId: 'pm' },
{ id: '3', state: 'SPARE', binId: 'b2', createdAt: daysAgo(1), partModelId: 'pm' },
{ id: '4', state: 'SPARE', binId: null, createdAt: daysAgo(1), partModelId: 'pm' },
],
openFms: 0,
eolPartModels: [],
bins: [
{ id: 'b1', name: 'A1', room: { name: 'Lab', site: { name: 'HQ' } } },
{ id: 'b2', name: 'B2', room: { name: 'Lab', site: { name: 'HQ' } } },
],
});
const r = await dashboard(tx);
const r = await dashboard(tx, { isAdmin: false });
expect(r.topBins).toEqual([
{ binId: 'b1', label: 'HQ / Lab / A1', count: 2 },
{ binId: 'b2', label: 'HQ / Lab / B2', count: 1 },
@@ -126,15 +179,14 @@ describe('analytics.dashboard', () => {
it('flags part models whose EOL has passed and have deployed parts', async () => {
const tx = makeTx({
...EMPTY,
partCount: 3,
stateRows: [],
parts: [
{ id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' },
],
openFms: 0,
eolPartModels: [
pastEolModels: [
{
id: 'pm1',
mpn: 'ACM-100',
@@ -157,10 +209,9 @@ describe('analytics.dashboard', () => {
manufacturer: { name: 'Gamma' },
},
],
bins: [],
});
const r = await dashboard(tx);
const r = await dashboard(tx, { isAdmin: false });
expect(r.deployedPastEol.map((m) => m.mpn)).toEqual(['ACM-100', 'BET-200']);
expect(r.deployedPastEol[0]).toMatchObject({
partModelId: 'pm1',
@@ -174,3 +225,122 @@ describe('analytics.dashboard', () => {
});
});
});
describe('analytics.dashboard — upcomingEol', () => {
it('lists models with upcoming EOL sorted by date, filters zero-deployed', async () => {
const tx = makeTx({
...EMPTY,
partCount: 3,
parts: [
{ id: '1', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm1' },
{ id: '2', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' },
{ id: '3', state: 'DEPLOYED', binId: null, createdAt: daysAgo(1), partModelId: 'pm2' },
],
upcomingEolModels: [
{
id: 'pm2',
mpn: 'LATER',
eolDate: daysAhead(150),
manufacturerId: 'm1',
manufacturer: { name: 'Acme' },
},
{
id: 'pm1',
mpn: 'SOONER',
eolDate: daysAhead(45),
manufacturerId: 'm1',
manufacturer: { name: 'Acme' },
},
{
id: 'pm3',
mpn: 'NODEP',
eolDate: daysAhead(30),
manufacturerId: 'm1',
manufacturer: { name: 'Acme' },
},
],
});
const r = await dashboard(tx, { isAdmin: false });
expect(r.upcomingEol.map((m) => m.mpn)).toEqual(['SOONER', 'LATER']);
expect(r.upcomingEol[0]).toMatchObject({ partModelId: 'pm1', deployedCount: 1 });
expect(r.upcomingEol[1]).toMatchObject({ partModelId: 'pm2', deployedCount: 2 });
});
});
describe('analytics.dashboard — isAdmin gating', () => {
it('omits operations when isAdmin is false', async () => {
const tx = makeTx(EMPTY);
const r = await dashboard(tx, { isAdmin: false });
expect(r.operations).toBeUndefined();
});
it('returns operations with expected shape when isAdmin is true', async () => {
const tx = makeTx({
...EMPTY,
repairs: [{ performedAt: daysAgo(1) }],
fmsClosed: [{ openedAt: daysAgo(2), closedAt: daysAgo(1) }],
newFms7d: 3,
openFmGroups: [{ hostId: 'h1', count: 2 }],
custodyGroups: [{ custodianId: 'u1', count: 1 }],
hosts: [{ id: 'h1', name: 'host-1' }],
users: [{ id: 'u1', username: 'alice' }],
});
const r = await dashboard(tx, { isAdmin: true });
expect(r.operations).toBeDefined();
expect(r.operations).toMatchObject({
repairs7d: 1,
repairs30d: 1,
newFms7d: 3,
});
expect(r.operations!.repairsTrend30d).toHaveLength(30);
expect(r.operations!.openFmsByHost).toEqual([{ hostId: 'h1', hostName: 'host-1', count: 2 }]);
expect(r.operations!.custodyBacklog).toEqual([
{ userId: 'u1', username: 'alice', count: 1 },
]);
});
});
describe('analytics.dashboard — operations fields', () => {
it('repairsTrend30d has 30 entries and zero-fills empty days', async () => {
const tx = makeTx({
...EMPTY,
repairs: [{ performedAt: daysAgo(5) }, { performedAt: daysAgo(28) }],
});
const r = await dashboard(tx, { isAdmin: true });
const trend = r.operations!.repairsTrend30d;
expect(trend).toHaveLength(30);
expect(trend.filter((d) => d.count === 0)).toHaveLength(28);
expect(trend.filter((d) => d.count === 1)).toHaveLength(2);
// Chronological order: earliest first, today last
for (let i = 1; i < trend.length; i++) {
expect(trend[i]!.date >= trend[i - 1]!.date).toBe(true);
}
});
it('avgFmCloseHours30d is null when no FMs closed in window', async () => {
const tx = makeTx({ ...EMPTY, fmsClosed: [] });
const r = await dashboard(tx, { isAdmin: true });
expect(r.operations!.avgFmCloseHours30d).toBeNull();
});
it('avgFmCloseHours30d averages close durations in hours', async () => {
const tx = makeTx({
...EMPTY,
fmsClosed: [
{
openedAt: new Date(now.getTime() - 4 * HOUR_MS),
closedAt: new Date(now.getTime() - 2 * HOUR_MS),
},
{
openedAt: new Date(now.getTime() - 10 * HOUR_MS),
closedAt: new Date(now.getTime() - 4 * HOUR_MS),
},
],
});
const r = await dashboard(tx, { isAdmin: true });
expect(r.operations!.avgFmCloseHours30d).toBe(4);
});
});
+184 -28
View File
@@ -1,7 +1,8 @@
import type { DashboardAnalytics } from '@vector/shared';
import type { DashboardAnalytics, OperationsAnalytics } from '@vector/shared';
import type { Tx } from './types.js';
const DAY = 24 * 60 * 60 * 1000;
const HOUR = 60 * 60 * 1000;
const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
{ label: '030d', maxDays: 30 },
@@ -12,29 +13,56 @@ const AGE_BUCKETS: { label: string; maxDays: number | null }[] = [
{ label: '2y+', maxDays: null },
];
export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
const [totalParts, stateRows, parts, openFms, partModelsWithEol] = await Promise.all([
tx.part.count(),
tx.part.groupBy({
by: ['state'],
_count: { _all: true },
_sum: { price: true },
}),
tx.part.findMany({
select: { id: true, state: true, binId: true, createdAt: true, partModelId: true },
}),
tx.fm.count({ where: { status: 'OPEN' } }),
tx.partModel.findMany({
where: { eolDate: { not: null, lte: new Date() } },
select: {
id: true,
mpn: true,
eolDate: true,
manufacturerId: true,
manufacturer: { select: { name: true } },
},
}),
]);
const CUSTODY_STATES = [
'PENDING_REPAIR',
'PENDING_DROP_IN_CUSTODY',
'PENDING_DESTRUCTION_IN_CUSTODY',
] as const;
function utcDateKey(d: Date): string {
return d.toISOString().slice(0, 10);
}
export async function dashboard(
tx: Tx,
opts: { isAdmin: boolean },
): Promise<DashboardAnalytics> {
const now = new Date();
const upcomingEolCutoff = new Date(now.getTime() + 180 * DAY);
const [totalParts, stateRows, parts, openFms, pastEolModels, upcomingEolModels] =
await Promise.all([
tx.part.count(),
tx.part.groupBy({
by: ['state'],
_count: { _all: true },
_sum: { price: true },
}),
tx.part.findMany({
select: { id: true, state: true, binId: true, createdAt: true, partModelId: true },
}),
tx.fm.count({ where: { status: 'OPEN' } }),
tx.partModel.findMany({
where: { eolDate: { not: null, lte: now } },
select: {
id: true,
mpn: true,
eolDate: true,
manufacturerId: true,
manufacturer: { select: { name: true } },
},
}),
tx.partModel.findMany({
where: { eolDate: { gt: now, lte: upcomingEolCutoff } },
select: {
id: true,
mpn: true,
eolDate: true,
manufacturerId: true,
manufacturer: { select: { name: true } },
},
}),
]);
const byState = stateRows.map((row) => ({
state: row.state as DashboardAnalytics['byState'][number]['state'],
@@ -42,10 +70,10 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
totalPrice: row._sum.price ?? 0,
}));
const now = Date.now();
const nowMs = now.getTime();
const buckets = AGE_BUCKETS.map((b) => ({ label: b.label, count: 0 }));
for (const part of parts) {
const ageDays = (now - part.createdAt.getTime()) / DAY;
const ageDays = (nowMs - part.createdAt.getTime()) / DAY;
const idx = AGE_BUCKETS.findIndex((b) => b.maxDays === null || ageDays <= b.maxDays);
const bucket = idx >= 0 ? buckets[idx] : undefined;
if (bucket) bucket.count += 1;
@@ -80,7 +108,7 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
if (part.state !== 'DEPLOYED') continue;
deployedByModel.set(part.partModelId, (deployedByModel.get(part.partModelId) ?? 0) + 1);
}
const deployedPastEol = partModelsWithEol
const deployedPastEol = pastEolModels
.map((m) => ({
partModelId: m.id,
mpn: m.mpn,
@@ -92,5 +120,133 @@ export async function dashboard(tx: Tx): Promise<DashboardAnalytics> {
.filter((m) => m.deployedCount > 0)
.sort((a, b) => b.deployedCount - a.deployedCount);
return { totalParts, byState, ageBuckets: buckets, topBins, deployedPastEol, openFms };
const upcomingEol = upcomingEolModels
.map((m) => ({
partModelId: m.id,
mpn: m.mpn,
manufacturerId: m.manufacturerId,
manufacturerName: m.manufacturer.name,
eolDate: m.eolDate ? m.eolDate.toISOString() : '',
deployedCount: deployedByModel.get(m.id) ?? 0,
}))
.filter((m) => m.deployedCount > 0)
.sort((a, b) => (a.eolDate < b.eolDate ? -1 : 1));
const base: DashboardAnalytics = {
totalParts,
byState,
ageBuckets: buckets,
topBins,
deployedPastEol,
upcomingEol,
openFms,
};
if (!opts.isAdmin) return base;
const sevenDaysAgo = new Date(nowMs - 7 * DAY);
const thirtyDaysAgo = new Date(nowMs - 30 * DAY);
const [
repairs7d,
repairs30d,
newFms7d,
closedFms,
recentRepairs,
openFmGroups,
custodyGroups,
] = await Promise.all([
tx.repair.count({ where: { performedAt: { gte: sevenDaysAgo } } }),
tx.repair.count({ where: { performedAt: { gte: thirtyDaysAgo } } }),
tx.fm.count({ where: { openedAt: { gte: sevenDaysAgo } } }),
tx.fm.findMany({
where: { closedAt: { gte: thirtyDaysAgo } },
select: { openedAt: true, closedAt: true },
}),
tx.repair.findMany({
where: { performedAt: { gte: thirtyDaysAgo } },
select: { performedAt: true },
}),
tx.fm.groupBy({
by: ['hostId'],
where: { status: 'OPEN' },
_count: { _all: true },
}),
tx.part.groupBy({
by: ['custodianId'],
where: {
custodianId: { not: null },
state: { in: CUSTODY_STATES as unknown as string[] },
},
_count: { _all: true },
}),
]);
const closedWithDates = closedFms.filter(
(f): f is { openedAt: Date; closedAt: Date } => f.closedAt !== null,
);
const avgFmCloseHours30d =
closedWithDates.length === 0
? null
: closedWithDates.reduce(
(sum, f) => sum + (f.closedAt.getTime() - f.openedAt.getTime()) / HOUR,
0,
) / closedWithDates.length;
const trendByDay = new Map<string, number>();
for (const r of recentRepairs) {
const key = utcDateKey(r.performedAt);
trendByDay.set(key, (trendByDay.get(key) ?? 0) + 1);
}
const repairsTrend30d: { date: string; count: number }[] = [];
for (let i = 29; i >= 0; i--) {
const d = new Date(nowMs - i * DAY);
const key = utcDateKey(d);
repairsTrend30d.push({ date: key, count: trendByDay.get(key) ?? 0 });
}
const topOpenFmHostIds = [...openFmGroups]
.sort((a, b) => b._count._all - a._count._all)
.slice(0, 8);
const openFmHostRows = topOpenFmHostIds.length
? await tx.host.findMany({
where: { id: { in: topOpenFmHostIds.map((g) => g.hostId) } },
select: { id: true, name: true },
})
: [];
const openFmHostNames = new Map(openFmHostRows.map((h) => [h.id, h.name]));
const openFmsByHost = topOpenFmHostIds.map((g) => ({
hostId: g.hostId,
hostName: openFmHostNames.get(g.hostId) ?? 'Unknown',
count: g._count._all,
}));
const topCustodians = [...custodyGroups]
.filter((g): g is typeof g & { custodianId: string } => g.custodianId !== null)
.sort((a, b) => b._count._all - a._count._all)
.slice(0, 8);
const custodyUsers = topCustodians.length
? await tx.user.findMany({
where: { id: { in: topCustodians.map((g) => g.custodianId) } },
select: { id: true, username: true },
})
: [];
const usernames = new Map(custodyUsers.map((u) => [u.id, u.username]));
const custodyBacklog = topCustodians.map((g) => ({
userId: g.custodianId,
username: usernames.get(g.custodianId) ?? 'Unknown',
count: g._count._all,
}));
const operations: OperationsAnalytics = {
repairs7d,
repairs30d,
newFms7d,
avgFmCloseHours30d,
repairsTrend30d,
openFmsByHost,
custodyBacklog,
};
return { ...base, operations };
}