feat(member-work-sync): add renderer status view model
This commit is contained in:
parent
253ddf293d
commit
99b0279841
3 changed files with 182 additions and 0 deletions
|
|
@ -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)}`,
|
||||
};
|
||||
}
|
||||
1
src/features/member-work-sync/renderer/index.ts
Normal file
1
src/features/member-work-sync/renderer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './adapters/memberWorkSyncStatusViewModel';
|
||||
|
|
@ -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>): 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 });
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue