feat(member-work-sync): add neutral renderer diagnostics

This commit is contained in:
777genius 2026-04-29 14:40:19 +03:00
parent 246f31bbdb
commit 98a7c915ed
5 changed files with 330 additions and 0 deletions

View file

@ -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),
};
}

View file

@ -1 +1,4 @@
export * from './adapters/memberWorkSyncStatusViewModel';
export * from './hooks/useMemberWorkSyncStatus';
export * from './ui/MemberWorkSyncBadge';
export * from './ui/MemberWorkSyncDetails';

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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');
});
});