feat(member-work-sync): add neutral renderer diagnostics
This commit is contained in:
parent
246f31bbdb
commit
98a7c915ed
5 changed files with 330 additions and 0 deletions
|
|
@ -0,0 +1,90 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { api } from '@renderer/api';
|
||||
|
||||
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||
import {
|
||||
toMemberWorkSyncStatusViewModel,
|
||||
type MemberWorkSyncStatusViewModel,
|
||||
} from '../adapters/memberWorkSyncStatusViewModel';
|
||||
|
||||
export interface UseMemberWorkSyncStatusOptions {
|
||||
teamName?: string | null;
|
||||
memberName?: string | null;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseMemberWorkSyncStatusResult {
|
||||
status: MemberWorkSyncStatus | null;
|
||||
viewModel: MemberWorkSyncStatusViewModel;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : 'Failed to load member work sync status.';
|
||||
}
|
||||
|
||||
export function useMemberWorkSyncStatus({
|
||||
teamName,
|
||||
memberName,
|
||||
enabled = true,
|
||||
}: UseMemberWorkSyncStatusOptions): UseMemberWorkSyncStatusResult {
|
||||
const [status, setStatus] = useState<MemberWorkSyncStatus | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const normalizedTeamName = teamName?.trim();
|
||||
const normalizedMemberName = memberName?.trim();
|
||||
|
||||
if (!enabled || !normalizedTeamName || !normalizedMemberName) {
|
||||
setStatus(null);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setStatus((current) =>
|
||||
current?.teamName === normalizedTeamName && current.memberName === normalizedMemberName
|
||||
? current
|
||||
: null
|
||||
);
|
||||
|
||||
api.memberWorkSync
|
||||
.getStatus({ teamName: normalizedTeamName, memberName: normalizedMemberName })
|
||||
.then((nextStatus) => {
|
||||
if (!cancelled) {
|
||||
setStatus(nextStatus);
|
||||
}
|
||||
})
|
||||
.catch((nextError: unknown) => {
|
||||
if (!cancelled) {
|
||||
setStatus(null);
|
||||
setError(getErrorMessage(nextError));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, memberName, refreshKey, teamName]);
|
||||
|
||||
return {
|
||||
status,
|
||||
viewModel: toMemberWorkSyncStatusViewModel(status),
|
||||
loading,
|
||||
error,
|
||||
refresh: () => setRefreshKey((current) => current + 1),
|
||||
};
|
||||
}
|
||||
|
|
@ -1 +1,4 @@
|
|||
export * from './adapters/memberWorkSyncStatusViewModel';
|
||||
export * from './hooks/useMemberWorkSyncStatus';
|
||||
export * from './ui/MemberWorkSyncBadge';
|
||||
export * from './ui/MemberWorkSyncDetails';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { cn } from '@renderer/lib/utils';
|
||||
import type React from 'react';
|
||||
|
||||
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||
import {
|
||||
toMemberWorkSyncStatusViewModel,
|
||||
type MemberWorkSyncStatusViewModel,
|
||||
} from '../adapters/memberWorkSyncStatusViewModel';
|
||||
|
||||
interface MemberWorkSyncBadgeProps {
|
||||
status?: MemberWorkSyncStatus | null;
|
||||
viewModel?: MemberWorkSyncStatusViewModel;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const toneClassName: Record<MemberWorkSyncStatusViewModel['tone'], string> = {
|
||||
neutral: 'border-[var(--color-border)] text-[var(--color-text-muted)]',
|
||||
success: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-300',
|
||||
working: 'border-sky-500/25 bg-sky-500/10 text-sky-300',
|
||||
attention: 'border-amber-500/25 bg-amber-500/10 text-amber-200',
|
||||
blocked: 'border-red-500/25 bg-red-500/10 text-red-300',
|
||||
};
|
||||
|
||||
export function MemberWorkSyncBadge({
|
||||
status,
|
||||
viewModel,
|
||||
className,
|
||||
}: MemberWorkSyncBadgeProps): React.ReactElement {
|
||||
const resolved = viewModel ?? toMemberWorkSyncStatusViewModel(status);
|
||||
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('cursor-default whitespace-nowrap font-medium', toneClassName[resolved.tone], className)}
|
||||
title={resolved.tooltip}
|
||||
>
|
||||
{resolved.label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { MemberWorkSyncBadge } from './MemberWorkSyncBadge';
|
||||
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||
import { toMemberWorkSyncStatusViewModel } from '../adapters/memberWorkSyncStatusViewModel';
|
||||
import type React from 'react';
|
||||
|
||||
interface MemberWorkSyncDetailsProps {
|
||||
status: MemberWorkSyncStatus | null;
|
||||
showDiagnostics?: boolean;
|
||||
}
|
||||
|
||||
function shortFingerprint(fingerprint?: string): string {
|
||||
if (!fingerprint) {
|
||||
return 'unknown';
|
||||
}
|
||||
const suffix = fingerprint.split(':').at(-1) ?? fingerprint;
|
||||
return suffix.length > 12 ? `${suffix.slice(0, 12)}...` : suffix;
|
||||
}
|
||||
|
||||
export function MemberWorkSyncDetails({
|
||||
status,
|
||||
showDiagnostics = false,
|
||||
}: MemberWorkSyncDetailsProps): React.ReactElement {
|
||||
const viewModel = toMemberWorkSyncStatusViewModel(status);
|
||||
const agendaItems = status?.agenda.items ?? [];
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 text-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-[var(--color-text)]">Member work sync</h3>
|
||||
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{viewModel.tooltip}</p>
|
||||
</div>
|
||||
<MemberWorkSyncBadge viewModel={viewModel} />
|
||||
</div>
|
||||
|
||||
<dl className="mt-3 grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">Actionable items</dt>
|
||||
<dd className="font-medium text-[var(--color-text)]">{viewModel.actionableCount}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">Fingerprint</dt>
|
||||
<dd className="font-mono text-[var(--color-text)]">
|
||||
{shortFingerprint(viewModel.fingerprint)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">Report</dt>
|
||||
<dd className="font-medium text-[var(--color-text)]">
|
||||
{viewModel.reportState ?? 'none'}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-[var(--color-text-muted)]">Shadow would nudge</dt>
|
||||
<dd className="font-medium text-[var(--color-text)]">
|
||||
{viewModel.wouldNudge ? 'yes' : 'no'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
{agendaItems.length > 0 ? (
|
||||
<ul className="mt-3 space-y-1 text-xs text-[var(--color-text-secondary)]">
|
||||
{agendaItems.slice(0, 3).map((item) => (
|
||||
<li key={`${item.kind}:${item.taskId}`} className="truncate">
|
||||
#{item.displayId ?? item.taskId.slice(0, 8)} - {item.kind} - {item.subject}
|
||||
</li>
|
||||
))}
|
||||
{agendaItems.length > 3 ? (
|
||||
<li className="text-[var(--color-text-muted)]">
|
||||
{agendaItems.length - 3} more actionable item(s)
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{showDiagnostics && status?.diagnostics.length ? (
|
||||
<p className="mt-3 text-xs text-[var(--color-text-muted)]">
|
||||
Diagnostics: {status.diagnostics.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
import React, { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
MemberWorkSyncBadge,
|
||||
MemberWorkSyncDetails,
|
||||
useMemberWorkSyncStatus,
|
||||
} from '@features/member-work-sync/renderer';
|
||||
|
||||
import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts';
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
getStatus: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/api', () => ({
|
||||
api: {
|
||||
memberWorkSync: {
|
||||
getStatus: apiMocks.getStatus,
|
||||
},
|
||||
},
|
||||
isElectronMode: () => true,
|
||||
}));
|
||||
|
||||
function makeStatus(overrides: Partial<MemberWorkSyncStatus> = {}): MemberWorkSyncStatus {
|
||||
return {
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
state: 'needs_sync',
|
||||
agenda: {
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||
fingerprint: 'agenda:v1:abcdef1234567890',
|
||||
items: [
|
||||
{
|
||||
taskId: 'task-1',
|
||||
displayId: '11111111',
|
||||
subject: 'Ship UI',
|
||||
kind: 'work',
|
||||
assignee: 'bob',
|
||||
priority: 'normal',
|
||||
reason: 'owned_pending_task',
|
||||
evidence: { status: 'pending', owner: 'bob' },
|
||||
},
|
||||
],
|
||||
diagnostics: [],
|
||||
},
|
||||
shadow: {
|
||||
reconciledBy: 'queue',
|
||||
wouldNudge: true,
|
||||
fingerprintChanged: false,
|
||||
},
|
||||
evaluatedAt: '2026-04-29T00:00:00.000Z',
|
||||
diagnostics: ['developer_only'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('member work sync renderer', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('loads read-only status through the renderer hook', async () => {
|
||||
apiMocks.getStatus.mockResolvedValue(makeStatus());
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
function Harness(): React.ReactElement {
|
||||
const state = useMemberWorkSyncStatus({ teamName: 'team-a', memberName: 'bob' });
|
||||
return React.createElement('div', null, state.loading ? 'Loading' : state.viewModel.label);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(apiMocks.getStatus).toHaveBeenCalledWith({ teamName: 'team-a', memberName: 'bob' });
|
||||
expect(host.textContent).toContain('Needs sync');
|
||||
});
|
||||
|
||||
it('renders neutral diagnostics without exposing raw diagnostics by default', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
'div',
|
||||
null,
|
||||
React.createElement(MemberWorkSyncBadge, { status: makeStatus() }),
|
||||
React.createElement(MemberWorkSyncDetails, { status: makeStatus() })
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Needs sync');
|
||||
expect(host.textContent).toContain('Shadow would nudge');
|
||||
expect(host.textContent).toContain('11111111');
|
||||
expect(host.textContent).not.toContain('developer_only');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue