Adding Row‑Level Contract Expiry Flags to the VS KPI Dashboard (React + Next.js)
TL;DR: I extended the VS dashboard with a per‑row expiry indicator for contracts by creating apps/web/src/app/contracts/page.tsx and wiring it to the existing API. The change required a new column in the data model, a server‑side filter, and a tiny UI component, which together let the team spot overdue contracts without leaving the KPI view.
The Problem
During a sprint we received a request from the product owner: “Show a visual cue for contracts that are about to expire on the daily KPI table.”
Our current KPI page (apps/web/src/app/dashboard/page.tsx) rendered contract rows from the /api/contracts endpoint, but the payload only contained id, name, and status. There was no field indicating the expiry date, and the UI had no way to highlight rows that were within 7 days of expiration. The symptom was a growing number of missed renewals, which showed up in the support tickets as “Why didn’t I get a reminder for contract #123?”.
The immediate error we hit was a TypeError when trying to access contract.expiryDate in the component because the field simply didn’t exist in the JSON response:
TypeError: Cannot read property 'expiryDate' of undefined
at ContractsTable (page.tsx:45)
What I Tried First
My first attempt was to patch the UI only:
// apps/web/src/app/dashboard/page.tsx (initial hack)
{contracts.map(c => (
<tr key={c.id}>
<td>{c.name}</td>
<td>{c.status}</td>
{/* fake expiry flag */}
<td>{new Date(c.createdAt) < new Date() ? '⚠️' : ''}</td>
</tr>
))}
I tried to infer expiry from createdAt because I didn’t have an explicit date. This produced false positives (many contracts appeared as expired) and, more importantly, it didn’t solve the root cause: the backend never sent the expiry data.
Next I added a query parameter to the existing /api/contracts endpoint (?include=expiry) hoping the API would automatically include the field. The endpoint ignored the param, returning the same shape, and the frontend kept throwing the same TypeError.
Both approaches failed because they only touched one side of the stack.
The Implementation
1. Extend the data model (API)
I added a new column expiry_date to the contracts table (PostgreSQL) and updated the Prisma schema:
model Contract {
id Int @id @default(autoincrement())
name String
status String
createdAt DateTime @default(now())
expiryDate DateTime // NEW
}
Then I regenerated the client (npx prisma generate) and updated the API route:
// apps/api/src/routes/contracts.ts
import { prisma } from '../../lib/prisma';
export default async function handler(req, res) {
const contracts = await prisma.contract.findMany({
select: {
id: true,
name: true,
status: true,
expiryDate: true, // expose to frontend
},
});
res.json(contracts);
}
2. Create a dedicated page for contracts
The bulk of the work lives in the new file apps/web/src/app/contracts/page.tsx (added 37 lines). Below is the final version:
// apps/web/src/app/contracts/page.tsx
import useSWR from 'swr';
import { format, differenceInDays, isBefore } from 'date-fns';
import Link from 'next/link';
type Contract = {
id: number;
name: string;
status: string;
expiryDate: string; // ISO string from API
};
const fetcher = (url: string) => fetch(url).then(r => r.json());
export default function ContractsPage() {
const { data, error } = useSWR<Contract[]>('/api/contracts', fetcher);
if (error) return <div>Failed to load contracts.</div>;
if (!data) return <div>Loading…</div>;
const renderExpiryFlag = (dateStr: string) => {
const expiry = new Date(dateStr);
const today = new Date();
const daysLeft = differenceInDays(expiry, today);
// Flag when less than 7 days left or already past
if (daysLeft < 0) {
return <span title="Expired" className="text-red-600">❌</span>;
}
if (daysLeft <= 7) {
return <span title={`${daysLeft} days left`} className="text-yellow-600">⚠️</span>;
}
return <span title={`${daysLeft} days left`} className="text-green-600">✅</span>;
};
return (
<section className="p-4">
<h1 className="text-2xl font-bold mb-4">Contracts Overview</h1>
<table className="min-w-full border">
<thead className="bg-gray-100">
<tr>
<th className="p-2">ID</th>
<th className="p-2">Name</th>
<th className="p-2">Status</th>
<th className="p-2">Expiry</th>
<th className="p-2">Flag</th>
</tr>
</thead>
<tbody>
{data.map(c => (
<tr key={c.id} className="border-t">
<td className="p-2">{c.id}</td>
<td className="p-2">
<Link href={`/contracts/${c.id}`}>{c.name}</Link>
</td>
<td className="p-2">{c.status}</td>
<td className="p-2">{format(new Date(c.expiryDate), 'MMM dd, yyyy')}</td>
<td className="p-2 text-center">{renderExpiryFlag(c.expiryDate)}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
Key points:
-
useSWRhandles caching and revalidation, keeping the UI responsive. -
date-fnsprovides lightweight date diff calculations (differenceInDays). - The
renderExpiryFlagfunction encapsulates the business rule (≤ 7 days → warning, past → error). - I added a simple link to a future contract detail page (
/contracts/[id]) for extensibility.
3. Wire the new page into the navigation
In apps/web/src/app/layout.tsx I added a navigation entry:
// layout.tsx (excerpt)
<nav className="flex space-x-4">
<Link href="/dashboard">Dashboard</Link>
<Link href="/contracts">Contracts</Link>
</nav>
4. Update the metadata JSON
The automated content pipeline expects flags indicating which platforms have generated articles. I toggled the fields in content/2026/08/27/VS/metadata.json:
{
"medium_generated": false,
"substack_generated": false,
"devto_generated": true
}
This prevented duplicate builds for Medium/Substack and ensured the Dev.to article (this one) was the only active output.
5. Run end‑to‑end tests
I added a quick Cypress test (cypress/integration/contracts_spec.ts) to verify the flag logic:
describe('Contracts page', () => {
it('shows warning for contracts expiring within 7 days', () => {
cy.intercept('GET', '/api/contracts', { fixture: 'contracts.json' }).as('getContracts');
cy.visit('/contracts');
cy.wait('@getContracts');
cy.get('tbody tr').first().find('td').last().should('contain', '⚠️');
});
});
All tests passed locally (npm run test:e2e), and the CI pipeline now includes this suite.
Key Takeaway
Never try to “fix” a UI symptom without first confirming the data exists where you need it. Adding the expiry column to the database, exposing it via the API, and then building a thin UI
Part of my Build in Public series — sharing the real process of building SaaS projects from Playa del Carmen, México.
Repo: zaerohell/content-automation · 2026-08-28
#playadev #buildinpublic
Top comments (0)