From 98a7c915ed459ded1734918f236cdabfb6fe75f9 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 14:40:19 +0300 Subject: [PATCH] feat(member-work-sync): add neutral renderer diagnostics --- .../renderer/hooks/useMemberWorkSyncStatus.ts | 90 ++++++++++++++ .../member-work-sync/renderer/index.ts | 3 + .../renderer/ui/MemberWorkSyncBadge.tsx | 41 +++++++ .../renderer/ui/MemberWorkSyncDetails.tsx | 83 +++++++++++++ .../renderer/memberWorkSyncRenderer.test.tsx | 113 ++++++++++++++++++ 5 files changed, 330 insertions(+) create mode 100644 src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts create mode 100644 src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx create mode 100644 src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx create mode 100644 test/features/member-work-sync/renderer/memberWorkSyncRenderer.test.tsx diff --git a/src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts b/src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts new file mode 100644 index 00000000..3c6a71ef --- /dev/null +++ b/src/features/member-work-sync/renderer/hooks/useMemberWorkSyncStatus.ts @@ -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(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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), + }; +} diff --git a/src/features/member-work-sync/renderer/index.ts b/src/features/member-work-sync/renderer/index.ts index 118ec417..60559156 100644 --- a/src/features/member-work-sync/renderer/index.ts +++ b/src/features/member-work-sync/renderer/index.ts @@ -1 +1,4 @@ export * from './adapters/memberWorkSyncStatusViewModel'; +export * from './hooks/useMemberWorkSyncStatus'; +export * from './ui/MemberWorkSyncBadge'; +export * from './ui/MemberWorkSyncDetails'; diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx new file mode 100644 index 00000000..000b8bc9 --- /dev/null +++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncBadge.tsx @@ -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 = { + 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 ( + + {resolved.label} + + ); +} diff --git a/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx b/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx new file mode 100644 index 00000000..81921c5d --- /dev/null +++ b/src/features/member-work-sync/renderer/ui/MemberWorkSyncDetails.tsx @@ -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 ( +
+
+
+

Member work sync

+

{viewModel.tooltip}

+
+ +
+ +
+
+
Actionable items
+
{viewModel.actionableCount}
+
+
+
Fingerprint
+
+ {shortFingerprint(viewModel.fingerprint)} +
+
+
+
Report
+
+ {viewModel.reportState ?? 'none'} +
+
+
+
Shadow would nudge
+
+ {viewModel.wouldNudge ? 'yes' : 'no'} +
+
+
+ + {agendaItems.length > 0 ? ( +
    + {agendaItems.slice(0, 3).map((item) => ( +
  • + #{item.displayId ?? item.taskId.slice(0, 8)} - {item.kind} - {item.subject} +
  • + ))} + {agendaItems.length > 3 ? ( +
  • + {agendaItems.length - 3} more actionable item(s) +
  • + ) : null} +
+ ) : null} + + {showDiagnostics && status?.diagnostics.length ? ( +

+ Diagnostics: {status.diagnostics.join(', ')} +

+ ) : null} +
+ ); +} diff --git a/test/features/member-work-sync/renderer/memberWorkSyncRenderer.test.tsx b/test/features/member-work-sync/renderer/memberWorkSyncRenderer.test.tsx new file mode 100644 index 00000000..7e19b1d4 --- /dev/null +++ b/test/features/member-work-sync/renderer/memberWorkSyncRenderer.test.tsx @@ -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 { + 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'); + }); +});