Skip to content

Commit 6e1a06f

Browse files
Marfuenclaude
andauthored
fix(devices): show stale as distinct state on /people device column (#2629)
* fix(devices): surface stale as its own state in people table device column The /people table's DEVICE column was collapsing stale devices into "Non-Compliant" while the employee drill-in correctly showed a gray "Stale (Nd)" badge. Surface stale as a distinct fourth state in the roll-up so the two views agree. - Extend MemberDeviceStatus with 'stale'. - Roll-up precedence: non-compliant > stale > compliant (a hard fail still wins; all-stale members show stale). - MemberRow renders a gray dot + muted 'Stale' label, matching the drill-in's DS secondary-badge weight. - 10 new/updated tests on compute-device-status-map; 2 new tests on MemberRow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(devices): add tooltip explaining Stale state on people table Next to the Stale device label, show a small Information icon that reveals a tooltip on hover: > This device's CompAI agent hasn't reported in over 7 days, so we > can't verify its current compliance. Ask the employee to update or > reinstall the agent. Discoverable cue for admins who haven't seen the state before and wouldn't otherwise know that Stale means "data is unknown, not non-compliant." Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(devices): consistent stale-state tooltip across device surfaces Replace native title attrs on the overall-stale badge with a shared info-icon + tooltip trigger on the device list, employee device tab, and device details header, matching the pattern already used on the people list. Copy is identical across surfaces so users see the same explanation wherever they encounter the Stale state. * fix(devices): preserve non-compliant across multiple fleet hosts per member Cubic P1/P2 feedback on PR #2629. The fleet-fallback loop in computeDeviceStatusMap had regressed to last-host-wins when I refactored for the stale state — two fleet hosts for the same member with mixed pass/fail outcomes would write whichever came last, potentially overwriting a real non-compliant with a later compliant. Restore the guard: once a member is non-compliant from a failing fleet host, subsequent hosts cannot downgrade them. Regression tests cover both iteration orders (fail-then-pass and pass-then-fail). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(devices): branch stale tooltip copy for never-reported devices --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 5daac69 commit 6e1a06f

10 files changed

Lines changed: 415 additions & 96 deletions

File tree

apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.test.tsx

Lines changed: 35 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -156,33 +156,43 @@ describe('EmployeeTasks device compliance badge', () => {
156156
expect(screen.getByText('Stale')).toBeInTheDocument();
157157
});
158158

159-
it('sets stale badge title tooltip based on daysSinceLastCheckIn', () => {
160-
const { rerender } = renderWithDevice(
161-
makeDevice({ complianceStatus: 'stale', daysSinceLastCheckIn: 9 }),
162-
);
163-
expect(screen.getByText('Stale (9d)').closest('[title]')?.getAttribute('title')).toBe(
164-
'No check-in in 9 days',
165-
);
159+
it('renders a stale-explainer tooltip trigger for a stale device', () => {
160+
renderWithDevice(makeDevice({ complianceStatus: 'stale', daysSinceLastCheckIn: 9 }));
161+
expect(
162+
screen.getByRole('button', { name: /What does Stale mean\?/i }),
163+
).toBeInTheDocument();
164+
});
166165

167-
rerender(
168-
<EmployeeTasks
169-
employee={baseEmployee}
170-
policies={[]}
171-
trainingVideos={[]}
172-
host={null as unknown as Host}
173-
fleetPolicies={[] as FleetPolicy[]}
174-
organization={baseOrganization}
175-
memberDevice={makeDevice({
176-
complianceStatus: 'stale',
177-
daysSinceLastCheckIn: null,
178-
lastCheckIn: null,
179-
})}
180-
hasHipaaFramework={false}
181-
hipaaCompletedAt={null}
182-
/>,
166+
it('renders the stale-explainer tooltip trigger when daysSinceLastCheckIn is null (never reported)', () => {
167+
renderWithDevice(
168+
makeDevice({
169+
complianceStatus: 'stale',
170+
daysSinceLastCheckIn: null,
171+
lastCheckIn: null,
172+
}),
183173
);
184-
expect(screen.getByText('Stale').closest('[title]')?.getAttribute('title')).toBe(
185-
'No check-ins recorded',
174+
expect(
175+
screen.getByRole('button', { name: /What does Stale mean\?/i }),
176+
).toBeInTheDocument();
177+
});
178+
179+
it('does not render the stale-explainer tooltip trigger for a compliant device', () => {
180+
renderWithDevice(makeDevice({ complianceStatus: 'compliant' }));
181+
expect(
182+
screen.queryByRole('button', { name: /What does Stale mean\?/i }),
183+
).not.toBeInTheDocument();
184+
});
185+
186+
it('does not render the stale-explainer tooltip trigger for a non-compliant device', () => {
187+
renderWithDevice(
188+
makeDevice({
189+
complianceStatus: 'non_compliant',
190+
isCompliant: false,
191+
diskEncryptionEnabled: false,
192+
}),
186193
);
194+
expect(
195+
screen.queryByRole('button', { name: /What does Stale mean\?/i }),
196+
).not.toBeInTheDocument();
187197
});
188198
});

apps/app/src/app/(app)/[orgId]/people/[employeeId]/components/EmployeeTasks.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { TrainingVideo } from '@/lib/data/training-videos';
44
import type { EmployeeTrainingVideoCompletion, Member, Organization, Policy, User } from '@db';
55

66
import { Card, CardContent, CardHeader, CardTitle } from '@trycompai/ui/card';
7+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip';
78
import {
89
Badge,
910
Section,
@@ -14,6 +15,7 @@ import {
1415
TabsTrigger,
1516
Text,
1617
} from '@trycompai/design-system';
18+
import { Information } from '@trycompai/design-system/icons';
1719
import { AlertCircle, Award, CheckCircle2, Download, Info } from 'lucide-react';
1820
import type { FleetPolicy, Host } from '../../devices/types';
1921
import type { DeviceWithChecks } from '../../devices/types';
@@ -56,18 +58,35 @@ function staleLabel(daysSinceLastCheckIn: number | null): string {
5658
return daysSinceLastCheckIn === null ? 'Stale' : `Stale (${daysSinceLastCheckIn}d)`;
5759
}
5860

59-
function staleTitle(daysSinceLastCheckIn: number | null): string {
61+
function staleTooltipCopy(daysSinceLastCheckIn: number | null): string {
6062
return daysSinceLastCheckIn === null
61-
? 'No check-ins recorded'
62-
: `No check-in in ${daysSinceLastCheckIn} days`;
63+
? "This device's CompAI agent hasn't reported any check-ins, so we can't verify its current compliance. Ask the employee to install or activate the agent."
64+
: "This device's CompAI agent hasn't reported in over 7 days, so we can't verify its current compliance. Ask the employee to update or reinstall the agent.";
6365
}
6466

6567
function DeviceComplianceBadge({ device }: { device: DeviceWithChecks }) {
6668
if (device.complianceStatus === 'stale') {
6769
return (
68-
<Badge variant="secondary" title={staleTitle(device.daysSinceLastCheckIn)}>
69-
{staleLabel(device.daysSinceLastCheckIn)}
70-
</Badge>
70+
<div className="flex items-center gap-1">
71+
<Badge variant="secondary">{staleLabel(device.daysSinceLastCheckIn)}</Badge>
72+
<TooltipProvider>
73+
<Tooltip>
74+
<TooltipTrigger asChild>
75+
<button
76+
type="button"
77+
aria-label="What does Stale mean?"
78+
className="inline-flex items-center text-muted-foreground hover:text-foreground"
79+
onClick={(e) => e.stopPropagation()}
80+
>
81+
<Information size={14} />
82+
</button>
83+
</TooltipTrigger>
84+
<TooltipContent className="max-w-xs text-xs">
85+
{staleTooltipCopy(device.daysSinceLastCheckIn)}
86+
</TooltipContent>
87+
</Tooltip>
88+
</TooltipProvider>
89+
</div>
7190
);
7291
}
7392
if (device.complianceStatus === 'compliant') {

apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.test.tsx

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,9 @@ const baseMember = {
6060

6161
const noop = vi.fn();
6262

63-
function renderMemberRow(deviceStatus?: 'compliant' | 'non-compliant' | 'not-installed') {
63+
function renderMemberRow(
64+
deviceStatus?: 'compliant' | 'non-compliant' | 'stale' | 'not-installed',
65+
) {
6466
return render(
6567
<table>
6668
<tbody>
@@ -109,6 +111,41 @@ describe('MemberRow device status', () => {
109111
expect(screen.getByText('Non-Compliant').className).toContain('text-foreground');
110112
});
111113

114+
it('shows "Stale" with gray dot and muted label when deviceStatus is stale', () => {
115+
const { container } = renderMemberRow('stale');
116+
expect(screen.getByText('Stale')).toBeInTheDocument();
117+
expect(screen.getByText('Stale').className).toContain('text-muted-foreground');
118+
expect(container.querySelector('.bg-gray-400')).toBeInTheDocument();
119+
});
120+
121+
it('renders an info tooltip trigger next to the Stale label', () => {
122+
renderMemberRow('stale');
123+
expect(
124+
screen.getByRole('button', { name: /What does Stale mean\?/i }),
125+
).toBeInTheDocument();
126+
});
127+
128+
it('does not render the Stale info tooltip for compliant devices', () => {
129+
renderMemberRow('compliant');
130+
expect(
131+
screen.queryByRole('button', { name: /What does Stale mean\?/i }),
132+
).not.toBeInTheDocument();
133+
});
134+
135+
it('does not render the Stale info tooltip for non-compliant devices', () => {
136+
renderMemberRow('non-compliant');
137+
expect(
138+
screen.queryByRole('button', { name: /What does Stale mean\?/i }),
139+
).not.toBeInTheDocument();
140+
});
141+
142+
it('does not render the Stale info tooltip for not-installed devices', () => {
143+
renderMemberRow('not-installed');
144+
expect(
145+
screen.queryByRole('button', { name: /What does Stale mean\?/i }),
146+
).not.toBeInTheDocument();
147+
});
148+
112149
it('does not show device status for platform admin', () => {
113150
const adminMember = {
114151
...baseMember,
@@ -175,8 +212,13 @@ describe('MemberRow device status', () => {
175212
expect(yellowDot).toBeInTheDocument();
176213
u2();
177214

178-
const { container: c3 } = renderMemberRow('not-installed');
179-
const redDot = c3.querySelector('.bg-red-400');
215+
const { container: c3, unmount: u3 } = renderMemberRow('stale');
216+
const grayDot = c3.querySelector('.bg-gray-400');
217+
expect(grayDot).toBeInTheDocument();
218+
u3();
219+
220+
const { container: c4 } = renderMemberRow('not-installed');
221+
const redDot = c4.querySelector('.bg-red-400');
180222
expect(redDot).toBeInTheDocument();
181223
});
182224

apps/app/src/app/(app)/[orgId]/people/all/components/MemberRow.tsx

Lines changed: 54 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
DropdownMenuItem,
2020
DropdownMenuTrigger,
2121
} from '@trycompai/ui/dropdown-menu';
22+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@trycompai/ui/tooltip';
2223
import { parseRolesString } from '@/lib/permissions';
2324
import type { Role } from '@db';
2425
import {
@@ -33,7 +34,7 @@ import {
3334
TableRow,
3435
Text,
3536
} from '@trycompai/design-system';
36-
import { Checkmark, Edit, Laptop, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons';
37+
import { Checkmark, Edit, Information, Laptop, OverflowMenuVertical, TrashCan } from '@trycompai/design-system/icons';
3738

3839
import { toast } from 'sonner';
3940
import { MultiRoleCombobox } from './MultiRoleCombobox';
@@ -52,7 +53,7 @@ interface MemberRowProps {
5253
isCurrentUserOwner: boolean;
5354
customRoles?: CustomRoleOption[];
5455
taskCompletion?: TaskCompletion;
55-
deviceStatus?: 'compliant' | 'non-compliant' | 'not-installed';
56+
deviceStatus?: 'compliant' | 'non-compliant' | 'stale' | 'not-installed';
5657
isDeviceStatusLoading?: boolean;
5758
}
5859

@@ -87,6 +88,34 @@ function parseRoles(role: Role | Role[] | string): string[] {
8788
return parseRolesString(role);
8889
}
8990

91+
type DeviceStatus = 'compliant' | 'non-compliant' | 'stale' | 'not-installed';
92+
93+
function getDeviceStatusDotClass(status: DeviceStatus): string {
94+
switch (status) {
95+
case 'compliant':
96+
return 'bg-green-500';
97+
case 'non-compliant':
98+
return 'bg-yellow-500';
99+
case 'stale':
100+
return 'bg-gray-400';
101+
case 'not-installed':
102+
return 'bg-red-400';
103+
}
104+
}
105+
106+
function getDeviceStatusLabel(status: DeviceStatus): string {
107+
switch (status) {
108+
case 'compliant':
109+
return 'Compliant';
110+
case 'non-compliant':
111+
return 'Non-Compliant';
112+
case 'stale':
113+
return 'Stale';
114+
case 'not-installed':
115+
return 'Not Installed';
116+
}
117+
}
118+
90119
export function MemberRow({
91120
member,
92121
onRemove,
@@ -251,27 +280,37 @@ export function MemberRow({
251280
) : (
252281
<div className="flex items-center gap-2">
253282
<span
254-
className={`inline-block h-2 w-2 rounded-full ${
255-
deviceStatus === 'compliant'
256-
? 'bg-green-500'
257-
: deviceStatus === 'non-compliant'
258-
? 'bg-yellow-500'
259-
: 'bg-red-400'
260-
}`}
283+
className={`inline-block h-2 w-2 rounded-full ${getDeviceStatusDotClass(deviceStatus)}`}
261284
/>
262285
<span
263286
className={`text-sm ${
264-
deviceStatus === 'not-installed'
287+
deviceStatus === 'not-installed' || deviceStatus === 'stale'
265288
? 'text-muted-foreground'
266289
: 'text-foreground'
267290
}`}
268291
>
269-
{deviceStatus === 'compliant'
270-
? 'Compliant'
271-
: deviceStatus === 'non-compliant'
272-
? 'Non-Compliant'
273-
: 'Not Installed'}
292+
{getDeviceStatusLabel(deviceStatus)}
274293
</span>
294+
{deviceStatus === 'stale' && (
295+
<TooltipProvider>
296+
<Tooltip>
297+
<TooltipTrigger asChild>
298+
<button
299+
type="button"
300+
aria-label="What does Stale mean?"
301+
className="inline-flex items-center text-muted-foreground hover:text-foreground"
302+
onClick={(e) => e.stopPropagation()}
303+
>
304+
<Information size={14} />
305+
</button>
306+
</TooltipTrigger>
307+
<TooltipContent className="max-w-xs text-xs">
308+
This device's CompAI agent hasn't reported in recently, so we can't verify
309+
its current compliance. Ask the employee to update or reinstall the agent.
310+
</TooltipContent>
311+
</Tooltip>
312+
</TooltipProvider>
313+
)}
275314
</div>
276315
)}
277316
</TableCell>

0 commit comments

Comments
 (0)