Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/metrics/review-metrics.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,4 @@
{"pr":711,"story":473,"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
{"pr":711,"story":473,"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
{"pr":711,"story":473,"agent":"ux-designer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0}}
{"pr":712,"issues":[474],"epic":9,"type":"feat","mergedAt":null,"filesChanged":11,"linesChanged":1100,"fixLoopCount":1,"reviews":[{"agent":"product-architect","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":2,"informational":0},"round":1},{"agent":"security-engineer","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"product-owner","verdict":"approve","findings":{"critical":0,"high":0,"medium":0,"low":0,"informational":0},"round":1},{"agent":"ux-designer","verdict":"request-changes","findings":{"critical":0,"high":2,"medium":3,"low":3,"informational":2},"round":1}],"totalFindings":{"critical":0,"high":2,"medium":3,"low":5,"informational":2}}
254 changes: 254 additions & 0 deletions client/src/components/TimelineStatusCards/AtRiskItemsCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
/**
* @jest-environment jsdom
*/
import { describe, it, expect, beforeEach } from '@jest/globals';
import { screen } from '@testing-library/react';
import { renderWithRouter } from '../../test/testUtils.js';
import type { TimelineWorkItem } from '@cornerstone/shared';

// CSS modules mocked via identity-obj-proxy

// Dynamic import — must happen after any jest.unstable_mockModule calls.
// AtRiskItemsCard has no context deps so no mocks are needed before the import.
let AtRiskItemsCard: React.ComponentType<{ workItems: TimelineWorkItem[] }>;

const baseWorkItem: TimelineWorkItem = {
id: 'wi-1',
title: 'Test Work Item',
status: 'not_started',
startDate: null,
endDate: null,
actualStartDate: null,
actualEndDate: null,
durationDays: null,
startAfter: null,
startBefore: null,
assignedUser: null,
tags: [],
};

// Use clearly past/future dates to avoid flaky tests:
// Past date: '2020-01-01' — well before any possible test execution date
// Future date: '2099-12-31' — well after any possible test execution date

describe('AtRiskItemsCard', () => {
beforeEach(async () => {
if (!AtRiskItemsCard) {
const module = await import('./AtRiskItemsCard.js');
AtRiskItemsCard = module.AtRiskItemsCard;
}
});

// ── Test 1: Empty state when workItems is empty ───────────────────────────

it('shows empty state with data-testid="risk-empty" and "All items on track" when workItems is empty', () => {
renderWithRouter(<AtRiskItemsCard workItems={[]} />);

const el = screen.getByTestId('risk-empty');
expect(el).toBeInTheDocument();
expect(el).toHaveTextContent('All items on track');
});

// ── Test 2: Empty state when no items are at risk ─────────────────────────

it('shows empty state when no items are at risk (all have future dates or are completed)', () => {
const workItems: TimelineWorkItem[] = [
{
...baseWorkItem,
id: 'wi-1',
status: 'in_progress',
endDate: '2099-12-31', // far future — not overdue
},
{
...baseWorkItem,
id: 'wi-2',
status: 'not_started',
startDate: '2099-12-31', // far future — not late start
},
{
...baseWorkItem,
id: 'wi-3',
status: 'completed',
endDate: '2020-01-01', // past, but completed → not at risk
},
];

renderWithRouter(<AtRiskItemsCard workItems={workItems} />);

const el = screen.getByTestId('risk-empty');
expect(el).toBeInTheDocument();
expect(screen.queryByTestId('risk-row')).toBeNull();
});

// ── Test 3: Shows overdue in_progress items (endDate in past) ────────────

it('shows a risk-row for in_progress items with an endDate in the past', () => {
const workItems: TimelineWorkItem[] = [
{
...baseWorkItem,
id: 'wi-overdue',
title: 'Overdue Task',
status: 'in_progress',
endDate: '2020-01-01', // clearly in the past
},
];

renderWithRouter(<AtRiskItemsCard workItems={workItems} />);

const rows = screen.getAllByTestId('risk-row');
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveTextContent('Overdue Task');
});

// ── Test 4: Shows late start not_started items (startDate in past) ────────

it('shows a risk-row for not_started items with a startDate in the past', () => {
const workItems: TimelineWorkItem[] = [
{
...baseWorkItem,
id: 'wi-late',
title: 'Late Start Task',
status: 'not_started',
startDate: '2020-01-01', // clearly in the past
},
];

renderWithRouter(<AtRiskItemsCard workItems={workItems} />);

const rows = screen.getAllByTestId('risk-row');
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveTextContent('Late Start Task');
});

// ── Test 5: Does NOT show completed items even if endDate is past ─────────

it('does not show risk-row for completed items even when endDate is in the past', () => {
const workItems: TimelineWorkItem[] = [
{
...baseWorkItem,
id: 'wi-completed',
title: 'Completed Task',
status: 'completed',
endDate: '2020-01-01', // past, but completed → never at risk
},
];

renderWithRouter(<AtRiskItemsCard workItems={workItems} />);

// Should show empty state since the completed item is not at risk
expect(screen.getByTestId('risk-empty')).toBeInTheDocument();
expect(screen.queryByTestId('risk-row')).toBeNull();
});

// ── Test 6: Shows "Overdue" reason badge for overdue items ───────────────

it('shows "Overdue" text in the data-testid="risk-reason" badge for overdue in_progress items', () => {
const workItems: TimelineWorkItem[] = [
{
...baseWorkItem,
id: 'wi-overdue',
title: 'Overdue Task',
status: 'in_progress',
endDate: '2020-01-01',
},
];

renderWithRouter(<AtRiskItemsCard workItems={workItems} />);

const reasonBadge = screen.getByTestId('risk-reason');
expect(reasonBadge).toHaveTextContent('Overdue');
});

// ── Test 7: Shows "Late Start" reason badge for late start items ──────────

it('shows "Late Start" text in the data-testid="risk-reason" badge for not_started items with past startDate', () => {
const workItems: TimelineWorkItem[] = [
{
...baseWorkItem,
id: 'wi-late',
title: 'Late Start Task',
status: 'not_started',
startDate: '2020-01-01',
},
];

renderWithRouter(<AtRiskItemsCard workItems={workItems} />);

const reasonBadge = screen.getByTestId('risk-reason');
expect(reasonBadge).toHaveTextContent('Late Start');
});

// ── Test 8: Shows max 5 items when more than 5 at-risk exist ─────────────

it('shows at most 5 risk-rows when more than 5 at-risk items exist', () => {
const workItems: TimelineWorkItem[] = [
{ ...baseWorkItem, id: 'wi-1', title: 'Overdue 1', status: 'in_progress', endDate: '2020-01-01' },
{ ...baseWorkItem, id: 'wi-2', title: 'Overdue 2', status: 'in_progress', endDate: '2020-01-02' },
{ ...baseWorkItem, id: 'wi-3', title: 'Overdue 3', status: 'in_progress', endDate: '2020-01-03' },
{ ...baseWorkItem, id: 'wi-4', title: 'Overdue 4', status: 'in_progress', endDate: '2020-01-04' },
{ ...baseWorkItem, id: 'wi-5', title: 'Overdue 5', status: 'in_progress', endDate: '2020-01-05' },
{ ...baseWorkItem, id: 'wi-6', title: 'Overdue 6', status: 'in_progress', endDate: '2020-01-06' },
{ ...baseWorkItem, id: 'wi-7', title: 'Overdue 7', status: 'in_progress', endDate: '2020-01-07' },
];

renderWithRouter(<AtRiskItemsCard workItems={workItems} />);

const rows = screen.getAllByTestId('risk-row');
expect(rows).toHaveLength(5);
});

// ── Test 9: Work item title is rendered as a link ─────────────────────────

it('renders the work item title as a link within the risk-row', () => {
const workItems: TimelineWorkItem[] = [
{
...baseWorkItem,
id: 'wi-overdue',
title: 'Overdue Task Link',
status: 'in_progress',
endDate: '2020-01-01',
},
];

renderWithRouter(<AtRiskItemsCard workItems={workItems} />);

const link = screen.getByRole('link', { name: 'Overdue Task Link' });
expect(link).toBeInTheDocument();
});

// ── Test 10: Items are sorted — most overdue first (earliest date first) ──

it('sorts at-risk items with the most overdue (earliest date) appearing first', () => {
const workItems: TimelineWorkItem[] = [
{
...baseWorkItem,
id: 'wi-newer',
title: 'Less Overdue',
status: 'in_progress',
endDate: '2020-06-01', // overdue but more recent
},
{
...baseWorkItem,
id: 'wi-oldest',
title: 'Most Overdue',
status: 'in_progress',
endDate: '2020-01-01', // oldest overdue date → should appear first
},
{
...baseWorkItem,
id: 'wi-middle',
title: 'Middle Overdue',
status: 'in_progress',
endDate: '2020-03-15',
},
];

renderWithRouter(<AtRiskItemsCard workItems={workItems} />);

const rows = screen.getAllByTestId('risk-row');
expect(rows).toHaveLength(3);
expect(rows[0]).toHaveTextContent('Most Overdue');
expect(rows[1]).toHaveTextContent('Middle Overdue');
expect(rows[2]).toHaveTextContent('Less Overdue');
});
});
77 changes: 77 additions & 0 deletions client/src/components/TimelineStatusCards/AtRiskItemsCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Link } from 'react-router-dom';
import type { TimelineWorkItem } from '@cornerstone/shared';
import styles from './TimelineStatusCards.module.css';

interface AtRiskItemsCardProps {
workItems: TimelineWorkItem[];
}

interface AtRiskItem {
id: string;
title: string;
reason: 'Overdue' | 'Late Start';
sortDate: string;
}

export function AtRiskItemsCard({ workItems }: AtRiskItemsCardProps) {
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD

// Find at-risk items
const atRiskItems: AtRiskItem[] = [];

for (const item of workItems) {
if (item.status === 'in_progress' && item.endDate && item.endDate < today) {
// Overdue: in progress and end date is in the past
atRiskItems.push({
id: item.id,
title: item.title,
reason: 'Overdue',
sortDate: item.endDate,
});
} else if (item.status === 'not_started' && item.startDate && item.startDate < today) {
// Late Start: not started and start date is in the past
atRiskItems.push({
id: item.id,
title: item.title,
reason: 'Late Start',
sortDate: item.startDate,
});
}
}

// Sort by date ascending (most overdue first), take first 5
atRiskItems.sort((a, b) => a.sortDate.localeCompare(b.sortDate));
const topRisks = atRiskItems.slice(0, 5);

if (topRisks.length === 0) {
return (
<div className={styles.cardSection}>
<h3 className={styles.sectionHeader}>At Risk Items</h3>
<p data-testid="risk-empty" className={styles.emptyState}>
All items on track
</p>
</div>
);
}

return (
<div className={styles.cardSection}>
<h3 className={styles.sectionHeader}>At Risk Items</h3>
<ul className={styles.list}>
{topRisks.map((item) => (
<li key={item.id} data-testid="risk-row" className={styles.listItem}>
<Link to={`/work-items/${item.id}`} className={styles.link}>
{item.title}
</Link>
<span
data-testid="risk-reason"
className={`${styles.badge} ${styles.badgeRed}`}
>
{item.reason}
</span>
</li>
))}
</ul>
</div>
);
}
Loading