From 99b027984138b31c1431772f4271e8b8204bceb5 Mon Sep 17 00:00:00 2001 From: 777genius Date: Wed, 29 Apr 2026 14:09:14 +0300 Subject: [PATCH] feat(member-work-sync): add renderer status view model --- .../adapters/memberWorkSyncStatusViewModel.ts | 91 +++++++++++++++++++ .../member-work-sync/renderer/index.ts | 1 + .../memberWorkSyncStatusViewModel.test.ts | 90 ++++++++++++++++++ 3 files changed, 182 insertions(+) create mode 100644 src/features/member-work-sync/renderer/adapters/memberWorkSyncStatusViewModel.ts create mode 100644 src/features/member-work-sync/renderer/index.ts create mode 100644 test/features/member-work-sync/renderer/memberWorkSyncStatusViewModel.test.ts diff --git a/src/features/member-work-sync/renderer/adapters/memberWorkSyncStatusViewModel.ts b/src/features/member-work-sync/renderer/adapters/memberWorkSyncStatusViewModel.ts new file mode 100644 index 00000000..1d296a43 --- /dev/null +++ b/src/features/member-work-sync/renderer/adapters/memberWorkSyncStatusViewModel.ts @@ -0,0 +1,91 @@ +import type { MemberWorkSyncStatus } from '../../contracts'; + +export type MemberWorkSyncViewTone = 'neutral' | 'success' | 'working' | 'attention' | 'blocked'; + +export interface MemberWorkSyncStatusViewModel { + label: 'Synced' | 'Working' | 'Needs sync' | 'Blocked' | 'Unknown'; + tone: MemberWorkSyncViewTone; + actionableCount: number; + tooltip: string; + fingerprint?: string; + leaseExpiresAt?: string; + reportState?: string; + wouldNudge?: boolean; +} + +function describeAgenda(count: number): string { + if (count === 0) { + return 'No actionable work items.'; + } + if (count === 1) { + return '1 actionable work item.'; + } + return `${count} actionable work items.`; +} + +export function toMemberWorkSyncStatusViewModel( + status: MemberWorkSyncStatus | null | undefined +): MemberWorkSyncStatusViewModel { + if (!status) { + return { + label: 'Unknown', + tone: 'neutral', + actionableCount: 0, + tooltip: 'Member work sync status has not been evaluated yet.', + }; + } + + const actionableCount = status.agenda.items.length; + const base = { + actionableCount, + fingerprint: status.agenda.fingerprint, + ...(status.report?.expiresAt ? { leaseExpiresAt: status.report.expiresAt } : {}), + ...(status.report?.state ? { reportState: status.report.state } : {}), + ...(status.shadow ? { wouldNudge: status.shadow.wouldNudge } : {}), + }; + + if (status.state === 'caught_up') { + return { + ...base, + label: 'Synced', + tone: 'success', + tooltip: `Synced with current work agenda. ${describeAgenda(actionableCount)}`, + }; + } + + if (status.state === 'still_working') { + return { + ...base, + label: 'Working', + tone: 'working', + tooltip: `Member reported still working on current agenda. ${describeAgenda(actionableCount)}`, + }; + } + + if (status.state === 'blocked') { + return { + ...base, + label: 'Blocked', + tone: 'blocked', + tooltip: `Member reported blocked on current agenda. ${describeAgenda(actionableCount)}`, + }; + } + + if (status.state === 'needs_sync') { + return { + ...base, + label: 'Needs sync', + tone: 'attention', + tooltip: `Shadow status only: current agenda has no valid member report. ${describeAgenda( + actionableCount + )}`, + }; + } + + return { + ...base, + label: 'Unknown', + tone: 'neutral', + tooltip: `Member work sync is not active for this member. ${describeAgenda(actionableCount)}`, + }; +} diff --git a/src/features/member-work-sync/renderer/index.ts b/src/features/member-work-sync/renderer/index.ts new file mode 100644 index 00000000..118ec417 --- /dev/null +++ b/src/features/member-work-sync/renderer/index.ts @@ -0,0 +1 @@ +export * from './adapters/memberWorkSyncStatusViewModel'; diff --git a/test/features/member-work-sync/renderer/memberWorkSyncStatusViewModel.test.ts b/test/features/member-work-sync/renderer/memberWorkSyncStatusViewModel.test.ts new file mode 100644 index 00000000..1e902e10 --- /dev/null +++ b/test/features/member-work-sync/renderer/memberWorkSyncStatusViewModel.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { toMemberWorkSyncStatusViewModel } from '@features/member-work-sync/renderer'; + +import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts'; + +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:abc', + 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: [], + }, + evaluatedAt: '2026-04-29T00:00:00.000Z', + diagnostics: [], + ...overrides, + }; +} + +describe('memberWorkSyncStatusViewModel', () => { + it('maps shadow needs-sync to a neutral diagnostic tooltip without warning copy', () => { + const viewModel = toMemberWorkSyncStatusViewModel( + makeStatus({ shadow: { reconciledBy: 'queue', wouldNudge: true, fingerprintChanged: false } }) + ); + + expect(viewModel).toMatchObject({ + label: 'Needs sync', + tone: 'attention', + actionableCount: 1, + wouldNudge: true, + }); + expect(viewModel.tooltip).toContain('Shadow status only'); + }); + + it('maps valid leases and caught-up states without exposing raw diagnostics', () => { + expect( + toMemberWorkSyncStatusViewModel( + makeStatus({ + state: 'still_working', + report: { + teamName: 'team-a', + memberName: 'bob', + state: 'still_working', + agendaFingerprint: 'agenda:v1:abc', + reportedAt: '2026-04-29T00:00:00.000Z', + expiresAt: '2026-04-29T00:10:00.000Z', + accepted: true, + }, + }) + ) + ).toMatchObject({ + label: 'Working', + tone: 'working', + leaseExpiresAt: '2026-04-29T00:10:00.000Z', + }); + + expect( + toMemberWorkSyncStatusViewModel( + makeStatus({ + state: 'caught_up', + agenda: { + teamName: 'team-a', + memberName: 'bob', + generatedAt: '2026-04-29T00:00:00.000Z', + fingerprint: 'agenda:v1:empty', + items: [], + diagnostics: [], + }, + }) + ) + ).toMatchObject({ label: 'Synced', tone: 'success', actionableCount: 0 }); + }); +});