@happyvertical/smrt-support 0.39.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/AGENTS.md +270 -0
  2. package/CLAUDE.md +1 -0
  3. package/LICENSE +7 -0
  4. package/dist/__smrt-register__.d.ts +2 -0
  5. package/dist/__smrt-register__.d.ts.map +1 -0
  6. package/dist/index.d.ts +5 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +4358 -0
  9. package/dist/index.js.map +1 -0
  10. package/dist/manifest.json +6770 -0
  11. package/dist/models/index.d.ts +17 -0
  12. package/dist/models/index.d.ts.map +1 -0
  13. package/dist/models/service-time-entry.d.ts +75 -0
  14. package/dist/models/service-time-entry.d.ts.map +1 -0
  15. package/dist/models/support-ai-run.d.ts +45 -0
  16. package/dist/models/support-ai-run.d.ts.map +1 -0
  17. package/dist/models/support-case-event.d.ts +25 -0
  18. package/dist/models/support-case-event.d.ts.map +1 -0
  19. package/dist/models/support-case.d.ts +125 -0
  20. package/dist/models/support-case.d.ts.map +1 -0
  21. package/dist/models/support-channel-binding.d.ts +47 -0
  22. package/dist/models/support-channel-binding.d.ts.map +1 -0
  23. package/dist/models/support-compensation-plan.d.ts +42 -0
  24. package/dist/models/support-compensation-plan.d.ts.map +1 -0
  25. package/dist/models/support-interaction.d.ts +49 -0
  26. package/dist/models/support-interaction.d.ts.map +1 -0
  27. package/dist/models/support-plan.d.ts +92 -0
  28. package/dist/models/support-plan.d.ts.map +1 -0
  29. package/dist/models/support-policy.d.ts +84 -0
  30. package/dist/models/support-policy.d.ts.map +1 -0
  31. package/dist/models/support-service-target.d.ts +90 -0
  32. package/dist/models/support-service-target.d.ts.map +1 -0
  33. package/dist/models/support-settlement.d.ts +58 -0
  34. package/dist/models/support-settlement.d.ts.map +1 -0
  35. package/dist/models/support-specialist.d.ts +71 -0
  36. package/dist/models/support-specialist.d.ts.map +1 -0
  37. package/dist/models/support-work-link.d.ts +31 -0
  38. package/dist/models/support-work-link.d.ts.map +1 -0
  39. package/dist/permissions.d.ts +32 -0
  40. package/dist/permissions.d.ts.map +1 -0
  41. package/dist/services/coverage-calendar.d.ts +53 -0
  42. package/dist/services/coverage-calendar.d.ts.map +1 -0
  43. package/dist/services/human-handoff-service.d.ts +113 -0
  44. package/dist/services/human-handoff-service.d.ts.map +1 -0
  45. package/dist/services/index.d.ts +15 -0
  46. package/dist/services/index.d.ts.map +1 -0
  47. package/dist/services/keyed-mutex.d.ts +22 -0
  48. package/dist/services/keyed-mutex.d.ts.map +1 -0
  49. package/dist/services/service-target-engine.d.ts +133 -0
  50. package/dist/services/service-target-engine.d.ts.map +1 -0
  51. package/dist/services/service-time-entry-service.d.ts +73 -0
  52. package/dist/services/service-time-entry-service.d.ts.map +1 -0
  53. package/dist/services/support-ai-workflow.d.ts +178 -0
  54. package/dist/services/support-ai-workflow.d.ts.map +1 -0
  55. package/dist/services/support-case-service.d.ts +162 -0
  56. package/dist/services/support-case-service.d.ts.map +1 -0
  57. package/dist/services/support-intake-service.d.ts +106 -0
  58. package/dist/services/support-intake-service.d.ts.map +1 -0
  59. package/dist/services/support-plan-admin-service.d.ts +45 -0
  60. package/dist/services/support-plan-admin-service.d.ts.map +1 -0
  61. package/dist/services/support-routing-service.d.ts +117 -0
  62. package/dist/services/support-routing-service.d.ts.map +1 -0
  63. package/dist/services/time-entry-approval-service.d.ts +162 -0
  64. package/dist/services/time-entry-approval-service.d.ts.map +1 -0
  65. package/dist/smrt-knowledge.json +4242 -0
  66. package/dist/svelte/components/CaseDetail.svelte +277 -0
  67. package/dist/svelte/components/CaseDetail.svelte.d.ts +10 -0
  68. package/dist/svelte/components/CaseDetail.svelte.d.ts.map +1 -0
  69. package/dist/svelte/components/CaseQueue.svelte +148 -0
  70. package/dist/svelte/components/CaseQueue.svelte.d.ts +12 -0
  71. package/dist/svelte/components/CaseQueue.svelte.d.ts.map +1 -0
  72. package/dist/svelte/components/RoutingRationale.svelte +131 -0
  73. package/dist/svelte/components/RoutingRationale.svelte.d.ts +11 -0
  74. package/dist/svelte/components/RoutingRationale.svelte.d.ts.map +1 -0
  75. package/dist/svelte/components/TargetList.svelte +127 -0
  76. package/dist/svelte/components/TargetList.svelte.d.ts +9 -0
  77. package/dist/svelte/components/TargetList.svelte.d.ts.map +1 -0
  78. package/dist/svelte/components/TimeEntryApprovalQueue.svelte +226 -0
  79. package/dist/svelte/components/TimeEntryApprovalQueue.svelte.d.ts +11 -0
  80. package/dist/svelte/components/TimeEntryApprovalQueue.svelte.d.ts.map +1 -0
  81. package/dist/svelte/components/__tests__/CaseDetail.test.js +86 -0
  82. package/dist/svelte/components/__tests__/CaseQueue.test.js +67 -0
  83. package/dist/svelte/components/__tests__/RoutingRationale.test.js +69 -0
  84. package/dist/svelte/components/__tests__/TargetList.test.js +84 -0
  85. package/dist/svelte/components/__tests__/TimeEntryApprovalQueue.test.js +105 -0
  86. package/dist/svelte/index.d.ts +25 -0
  87. package/dist/svelte/index.d.ts.map +1 -0
  88. package/dist/svelte/index.js +18 -0
  89. package/dist/svelte/types.d.ts +129 -0
  90. package/dist/svelte/types.d.ts.map +1 -0
  91. package/dist/svelte/types.js +182 -0
  92. package/dist/types.d.ts +130 -0
  93. package/dist/types.d.ts.map +1 -0
  94. package/dist/types.js +109 -0
  95. package/dist/types.js.map +1 -0
  96. package/package.json +86 -0
@@ -0,0 +1,127 @@
1
+ <script lang="ts">
2
+ /**
3
+ * TargetList — Service Target clocks on a case at a glance (issue #1929):
4
+ * target type, due time, clock status, and a paused indicator per row.
5
+ * Presentational: the host loads targets and adapts them with
6
+ * `toServiceTargetView`.
7
+ */
8
+
9
+ import { StatusBadge } from '@happyvertical/smrt-ui';
10
+ import {
11
+ humanizeStatus,
12
+ type ServiceTargetView,
13
+ targetStatusBadgeKey,
14
+ } from '../types.js';
15
+
16
+ export interface TargetListProps {
17
+ targets: ServiceTargetView[];
18
+ emptyMessage?: string;
19
+ }
20
+
21
+ const { targets, emptyMessage = 'No service targets' }: TargetListProps =
22
+ $props();
23
+
24
+ function formatDue(iso: string | null): string {
25
+ if (!iso) return '';
26
+ const date = new Date(iso);
27
+ if (Number.isNaN(date.getTime())) return '';
28
+ return date.toLocaleString(undefined, {
29
+ month: 'short',
30
+ day: 'numeric',
31
+ hour: '2-digit',
32
+ minute: '2-digit',
33
+ });
34
+ }
35
+ </script>
36
+
37
+ {#if targets.length === 0}
38
+ <p class="target-list-empty">{emptyMessage}</p>
39
+ {:else}
40
+ <ul class="target-list">
41
+ {#each targets as target (target.id)}
42
+ <li class="target-list-row">
43
+ <span class="target-list-main">
44
+ <span class="target-list-type">
45
+ {humanizeStatus(target.targetType)}
46
+ {#if target.cycle > 0}
47
+ <span class="target-list-cycle">cycle {target.cycle}</span>
48
+ {/if}
49
+ </span>
50
+ {#if target.dueAt}
51
+ <span class="target-list-due">due {formatDue(target.dueAt)}</span>
52
+ {/if}
53
+ </span>
54
+ <span class="target-list-meta">
55
+ {#if target.paused}
56
+ <span class="target-list-paused">paused</span>
57
+ {/if}
58
+ <StatusBadge
59
+ status={targetStatusBadgeKey(target.status)}
60
+ label={humanizeStatus(target.status)}
61
+ size="sm"
62
+ />
63
+ </span>
64
+ </li>
65
+ {/each}
66
+ </ul>
67
+ {/if}
68
+
69
+ <style>
70
+ .target-list {
71
+ list-style: none;
72
+ margin: 0;
73
+ padding: 0;
74
+ display: flex;
75
+ flex-direction: column;
76
+ gap: 0.375rem;
77
+ }
78
+
79
+ .target-list-row {
80
+ display: flex;
81
+ align-items: center;
82
+ justify-content: space-between;
83
+ gap: 0.75rem;
84
+ }
85
+
86
+ .target-list-main {
87
+ display: flex;
88
+ flex-direction: column;
89
+ gap: 0.125rem;
90
+ min-width: 0;
91
+ }
92
+
93
+ .target-list-type {
94
+ font-weight: 500;
95
+ text-transform: capitalize;
96
+ }
97
+
98
+ .target-list-cycle {
99
+ font-size: 0.75rem;
100
+ font-weight: 400;
101
+ text-transform: none;
102
+ color: var(--smrt-color-on-surface-variant, inherit);
103
+ margin-left: 0.375rem;
104
+ }
105
+
106
+ .target-list-due,
107
+ .target-list-paused {
108
+ font-size: 0.75rem;
109
+ color: var(--smrt-color-on-surface-variant, inherit);
110
+ }
111
+
112
+ .target-list-paused {
113
+ font-style: italic;
114
+ }
115
+
116
+ .target-list-meta {
117
+ display: flex;
118
+ align-items: center;
119
+ gap: 0.5rem;
120
+ flex-shrink: 0;
121
+ }
122
+
123
+ .target-list-empty {
124
+ color: var(--smrt-color-on-surface-variant, inherit);
125
+ padding: 1rem 0;
126
+ }
127
+ </style>
@@ -0,0 +1,9 @@
1
+ import { type ServiceTargetView } from '../types.js';
2
+ export interface TargetListProps {
3
+ targets: ServiceTargetView[];
4
+ emptyMessage?: string;
5
+ }
6
+ declare const TargetList: import("svelte").Component<TargetListProps, {}, "">;
7
+ type TargetList = ReturnType<typeof TargetList>;
8
+ export default TargetList;
9
+ //# sourceMappingURL=TargetList.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TargetList.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TargetList.svelte.ts"],"names":[],"mappings":"AAUA,OAAO,EAEL,KAAK,iBAAiB,EAEvB,MAAM,aAAa,CAAC;AAGrB,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,iBAAiB,EAAE,CAAC;IAC7B,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAwDD,QAAA,MAAM,UAAU,qDAAwC,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -0,0 +1,226 @@
1
+ <script lang="ts">
2
+ /**
3
+ * TimeEntryApprovalQueue — reusable Service Time Entry review list (issue
4
+ * #1930): date, hours, description, worker, status, and the client charge
5
+ * amount at a glance, with approve/reject actions on `submitted` rows.
6
+ * Presentational: the host loads entries through `TimeEntryApprovalService`
7
+ * / `ServiceTimeEntryCollection`, adapts them with `toSupportTimeEntryView`,
8
+ * and wires `onapprove` / `onreject` to the approval service.
9
+ */
10
+
11
+ import { Button, StatusBadge } from '@happyvertical/smrt-ui';
12
+ import { Input } from '@happyvertical/smrt-ui/forms';
13
+ import {
14
+ humanizeStatus,
15
+ type SupportTimeEntryView,
16
+ timeEntryStatusBadgeKey,
17
+ } from '../types.js';
18
+
19
+ export interface TimeEntryApprovalQueueProps {
20
+ entries: SupportTimeEntryView[];
21
+ onapprove?: (id: string) => void;
22
+ onreject?: (id: string, reason: string) => void;
23
+ emptyMessage?: string;
24
+ }
25
+
26
+ const {
27
+ entries,
28
+ onapprove,
29
+ onreject,
30
+ emptyMessage = 'No time entries',
31
+ }: TimeEntryApprovalQueueProps = $props();
32
+
33
+ /** The entry currently collecting a rejection reason (one at a time). */
34
+ let rejectingId = $state<string | null>(null);
35
+ let rejectReason = $state('');
36
+
37
+ function beginReject(id: string): void {
38
+ rejectingId = id;
39
+ rejectReason = '';
40
+ }
41
+
42
+ function cancelReject(): void {
43
+ rejectingId = null;
44
+ rejectReason = '';
45
+ }
46
+
47
+ function confirmReject(id: string): void {
48
+ const reason = rejectReason.trim();
49
+ if (!reason) return;
50
+ onreject?.(id, reason);
51
+ cancelReject();
52
+ }
53
+
54
+ function formatHours(hours: number): string {
55
+ return `${hours.toFixed(1)}h`;
56
+ }
57
+
58
+ function formatAmount(amount: number): string {
59
+ return amount.toFixed(2);
60
+ }
61
+ </script>
62
+
63
+ {#if entries.length === 0}
64
+ <p class="time-approval-empty">{emptyMessage}</p>
65
+ {:else}
66
+ <ul class="time-approval-queue">
67
+ {#each entries as entry (entry.id)}
68
+ <li class="time-approval-row">
69
+ <div class="time-approval-line">
70
+ <span class="time-approval-date">{entry.date}</span>
71
+ <span class="time-approval-main">
72
+ <span class="time-approval-description">{entry.description}</span>
73
+ {#if entry.workerName}
74
+ <span class="time-approval-worker">{entry.workerName}</span>
75
+ {/if}
76
+ </span>
77
+ <span class="time-approval-meta">
78
+ <span class="time-approval-hours">{formatHours(entry.hours)}</span>
79
+ <StatusBadge
80
+ status={timeEntryStatusBadgeKey(entry.status)}
81
+ label={humanizeStatus(entry.status)}
82
+ size="sm"
83
+ />
84
+ {#if entry.amount !== undefined}
85
+ <span class="time-approval-amount">
86
+ {formatAmount(entry.amount)}
87
+ </span>
88
+ {/if}
89
+ </span>
90
+ </div>
91
+ {#if entry.status === 'submitted'}
92
+ <div class="time-approval-actions">
93
+ {#if rejectingId === entry.id}
94
+ <Input
95
+ bind:value={rejectReason}
96
+ placeholder="Rejection reason"
97
+ aria-label={`Rejection reason for: ${entry.description}`}
98
+ />
99
+ <Button
100
+ variant="danger"
101
+ size="sm"
102
+ disabled={rejectReason.trim().length === 0}
103
+ onclick={() => confirmReject(entry.id)}
104
+ aria-label={`Confirm rejection of: ${entry.description}`}
105
+ >
106
+ Confirm reject
107
+ </Button>
108
+ <Button
109
+ variant="ghost"
110
+ size="sm"
111
+ onclick={cancelReject}
112
+ aria-label={`Cancel rejecting: ${entry.description}`}
113
+ >
114
+ Cancel
115
+ </Button>
116
+ {:else}
117
+ <Button
118
+ variant="primary"
119
+ size="sm"
120
+ onclick={() => onapprove?.(entry.id)}
121
+ aria-label={`Approve time entry: ${entry.description}`}
122
+ >
123
+ Approve
124
+ </Button>
125
+ <Button
126
+ variant="ghost"
127
+ size="sm"
128
+ onclick={() => beginReject(entry.id)}
129
+ aria-label={`Reject time entry: ${entry.description}`}
130
+ >
131
+ Reject
132
+ </Button>
133
+ {/if}
134
+ </div>
135
+ {/if}
136
+ </li>
137
+ {/each}
138
+ </ul>
139
+ {/if}
140
+
141
+ <style>
142
+ .time-approval-queue {
143
+ list-style: none;
144
+ margin: 0;
145
+ padding: 0;
146
+ display: flex;
147
+ flex-direction: column;
148
+ gap: 0.25rem;
149
+ }
150
+
151
+ .time-approval-row {
152
+ display: flex;
153
+ flex-direction: column;
154
+ gap: 0.375rem;
155
+ padding: 0.5rem 0.25rem;
156
+ border-bottom: 1px solid var(--smrt-color-outline-variant, transparent);
157
+ }
158
+
159
+ .time-approval-row:last-child {
160
+ border-bottom: none;
161
+ }
162
+
163
+ .time-approval-line {
164
+ display: flex;
165
+ width: 100%;
166
+ align-items: center;
167
+ justify-content: space-between;
168
+ gap: 0.75rem;
169
+ text-align: left;
170
+ }
171
+
172
+ .time-approval-date {
173
+ flex-shrink: 0;
174
+ font-size: 0.75rem;
175
+ color: var(--smrt-color-on-surface-variant, inherit);
176
+ }
177
+
178
+ .time-approval-main {
179
+ display: flex;
180
+ flex-direction: column;
181
+ gap: 0.125rem;
182
+ min-width: 0;
183
+ flex: 1;
184
+ }
185
+
186
+ .time-approval-description {
187
+ font-weight: 500;
188
+ overflow: hidden;
189
+ text-overflow: ellipsis;
190
+ white-space: nowrap;
191
+ }
192
+
193
+ .time-approval-worker {
194
+ font-size: 0.75rem;
195
+ color: var(--smrt-color-on-surface-variant, inherit);
196
+ }
197
+
198
+ .time-approval-meta {
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 0.5rem;
202
+ flex-shrink: 0;
203
+ }
204
+
205
+ .time-approval-hours {
206
+ font-weight: 500;
207
+ color: var(--smrt-color-primary, inherit);
208
+ }
209
+
210
+ .time-approval-amount {
211
+ font-variant-numeric: tabular-nums;
212
+ font-weight: 500;
213
+ }
214
+
215
+ .time-approval-actions {
216
+ display: flex;
217
+ align-items: center;
218
+ gap: 0.5rem;
219
+ justify-content: flex-end;
220
+ }
221
+
222
+ .time-approval-empty {
223
+ color: var(--smrt-color-on-surface-variant, inherit);
224
+ padding: 1rem 0;
225
+ }
226
+ </style>
@@ -0,0 +1,11 @@
1
+ import { type SupportTimeEntryView } from '../types.js';
2
+ export interface TimeEntryApprovalQueueProps {
3
+ entries: SupportTimeEntryView[];
4
+ onapprove?: (id: string) => void;
5
+ onreject?: (id: string, reason: string) => void;
6
+ emptyMessage?: string;
7
+ }
8
+ declare const TimeEntryApprovalQueue: import("svelte").Component<TimeEntryApprovalQueueProps, {}, "">;
9
+ type TimeEntryApprovalQueue = ReturnType<typeof TimeEntryApprovalQueue>;
10
+ export default TimeEntryApprovalQueue;
11
+ //# sourceMappingURL=TimeEntryApprovalQueue.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"TimeEntryApprovalQueue.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TimeEntryApprovalQueue.svelte.ts"],"names":[],"mappings":"AAaA,OAAO,EAEL,KAAK,oBAAoB,EAE1B,MAAM,aAAa,CAAC;AAGrB,MAAM,WAAW,2BAA2B;IAC1C,OAAO,EAAE,oBAAoB,EAAE,CAAC;IAChC,SAAS,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,IAAI,CAAC;IACjC,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IAChD,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAmGD,QAAA,MAAM,sBAAsB,iEAAwC,CAAC;AACrE,KAAK,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACxE,eAAe,sBAAsB,CAAC"}
@@ -0,0 +1,86 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Component coverage for CaseDetail via the shared S11 harness (#1416).
4
+ */
5
+ import { expectNoA11yViolations, render, screen, } from '@happyvertical/smrt-vitest/svelte';
6
+ import { describe, expect, it } from 'vitest';
7
+ import CaseDetail from '../CaseDetail.svelte';
8
+ const caseView = {
9
+ id: 'case-1',
10
+ caseNumber: 'SUP-9',
11
+ subject: 'Checkout errors under load',
12
+ status: 'escalated',
13
+ priority: 'urgent',
14
+ severity: 'sev1',
15
+ channelKind: 'email',
16
+ clientProfileId: 'client-1',
17
+ projectId: 'shop-rebuild',
18
+ assignedSpecialistId: 'spec-2',
19
+ assignedSpecialistName: 'Grace',
20
+ reopenCount: 1,
21
+ createdAt: '2026-07-01T09:00:00.000Z',
22
+ updatedAt: '2026-07-03T16:00:00.000Z',
23
+ resolutionSummary: '',
24
+ };
25
+ const timeline = [
26
+ {
27
+ kind: 'interaction',
28
+ occurredAt: '2026-07-01T09:00:00.000Z',
29
+ actorKind: 'client',
30
+ summary: 'inbound email',
31
+ body: 'Checkout is failing for many users.',
32
+ direction: 'inbound',
33
+ channelKind: 'email',
34
+ eventType: null,
35
+ },
36
+ {
37
+ kind: 'event',
38
+ occurredAt: '2026-07-01T09:05:00.000Z',
39
+ actorKind: 'system',
40
+ summary: 'Status new → triaged',
41
+ body: '',
42
+ direction: null,
43
+ channelKind: null,
44
+ eventType: 'transition',
45
+ },
46
+ ];
47
+ const workLinks = [
48
+ {
49
+ id: 'link-1',
50
+ linkKind: 'development_work_item',
51
+ targetLabel: 'shop#101: fix checkout race',
52
+ externalUrl: 'https://github.com/acme/shop/issues/101',
53
+ status: 'in_progress',
54
+ },
55
+ ];
56
+ describe('CaseDetail', () => {
57
+ it('renders header, meta, linked work, and the merged timeline', async () => {
58
+ const { container } = render(CaseDetail, {
59
+ props: { caseView, timeline, workLinks },
60
+ });
61
+ expect(screen.getByText('SUP-9')).toBeInTheDocument();
62
+ expect(screen.getByText('Checkout errors under load')).toBeInTheDocument();
63
+ expect(screen.getByText('escalated')).toBeInTheDocument();
64
+ expect(screen.getByText('reopened ×1')).toBeInTheDocument();
65
+ expect(screen.getByText('Grace')).toBeInTheDocument();
66
+ expect(screen.getByText('shop-rebuild')).toBeInTheDocument();
67
+ expect(screen.getByRole('link', { name: 'shop#101: fix checkout race' })).toHaveAttribute('href', 'https://github.com/acme/shop/issues/101');
68
+ expect(screen.getByText('Checkout is failing for many users.')).toBeInTheDocument();
69
+ expect(screen.getByText('Status new → triaged')).toBeInTheDocument();
70
+ await expectNoA11yViolations(container);
71
+ });
72
+ it('shows the empty history state and resolution when present', () => {
73
+ render(CaseDetail, {
74
+ props: {
75
+ caseView: {
76
+ ...caseView,
77
+ status: 'resolved',
78
+ resolutionSummary: 'Scaled the checkout workers.',
79
+ },
80
+ timeline: [],
81
+ },
82
+ });
83
+ expect(screen.getByText('No activity yet')).toBeInTheDocument();
84
+ expect(screen.getByText('Scaled the checkout workers.')).toBeInTheDocument();
85
+ });
86
+ });
@@ -0,0 +1,67 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Component coverage for CaseQueue via the shared S11 harness (#1416).
4
+ */
5
+ import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import CaseQueue from '../CaseQueue.svelte';
8
+ function caseView(overrides = {}) {
9
+ return {
10
+ id: 'case-1',
11
+ caseNumber: 'SUP-1',
12
+ subject: 'Login is broken',
13
+ status: 'in_progress',
14
+ priority: 'high',
15
+ severity: 'sev2',
16
+ channelKind: 'chat',
17
+ clientProfileId: 'client-1',
18
+ projectId: 'project-1',
19
+ assignedSpecialistId: 'spec-1',
20
+ assignedSpecialistName: 'Ada',
21
+ reopenCount: 0,
22
+ createdAt: '2026-07-01T10:00:00.000Z',
23
+ updatedAt: '2026-07-02T11:30:00.000Z',
24
+ resolutionSummary: '',
25
+ ...overrides,
26
+ };
27
+ }
28
+ describe('CaseQueue', () => {
29
+ it('renders case number, subject, badges, and assignee', async () => {
30
+ const { container } = render(CaseQueue, {
31
+ props: { cases: [caseView()] },
32
+ });
33
+ expect(screen.getByText('SUP-1')).toBeInTheDocument();
34
+ expect(screen.getByText('Login is broken')).toBeInTheDocument();
35
+ expect(screen.getByText('high')).toBeInTheDocument();
36
+ expect(screen.getByText('in progress')).toBeInTheDocument();
37
+ expect(screen.getByText('Ada')).toBeInTheDocument();
38
+ await expectNoA11yViolations(container);
39
+ });
40
+ it('invokes onselect with the case id', async () => {
41
+ const onselect = vi.fn();
42
+ render(CaseQueue, { props: { cases: [caseView()], onselect } });
43
+ await userEvent.click(screen.getByRole('button', {
44
+ name: 'Open case SUP-1: Login is broken',
45
+ }));
46
+ expect(onselect).toHaveBeenCalledWith('case-1');
47
+ });
48
+ it('shows the empty message when there are no cases', () => {
49
+ render(CaseQueue, {
50
+ props: { cases: [], emptyMessage: 'Queue is clear' },
51
+ });
52
+ expect(screen.getByText('Queue is clear')).toBeInTheDocument();
53
+ });
54
+ it('falls back to unassigned when no specialist is set', () => {
55
+ render(CaseQueue, {
56
+ props: {
57
+ cases: [
58
+ caseView({
59
+ assignedSpecialistId: null,
60
+ assignedSpecialistName: null,
61
+ }),
62
+ ],
63
+ },
64
+ });
65
+ expect(screen.getByText('unassigned')).toBeInTheDocument();
66
+ });
67
+ });
@@ -0,0 +1,69 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Component coverage for RoutingRationale via the shared S11 harness (#1416).
4
+ */
5
+ import { expectNoA11yViolations, render, screen, userEvent, } from '@happyvertical/smrt-vitest/svelte';
6
+ import { describe, expect, it, vi } from 'vitest';
7
+ import RoutingRationale from '../RoutingRationale.svelte';
8
+ function rankedView(overrides = {}) {
9
+ return {
10
+ specialistId: 'spec-1',
11
+ displayName: 'Ada Lovelace',
12
+ score: 45,
13
+ eligible: true,
14
+ factors: {
15
+ status: 'active',
16
+ projectQualification: 'expert',
17
+ weeklyAvailable: true,
18
+ openCases: 2,
19
+ },
20
+ ...overrides,
21
+ };
22
+ }
23
+ describe('RoutingRationale', () => {
24
+ it('renders name, score, eligibility, and factor chips', async () => {
25
+ const { container } = render(RoutingRationale, {
26
+ props: {
27
+ ranking: [
28
+ rankedView(),
29
+ rankedView({
30
+ specialistId: 'spec-2',
31
+ displayName: 'Grace Hopper',
32
+ score: 0,
33
+ eligible: false,
34
+ factors: { status: 'active', projectQualification: 'none' },
35
+ }),
36
+ ],
37
+ },
38
+ });
39
+ expect(screen.getByText('Ada Lovelace')).toBeInTheDocument();
40
+ expect(screen.getByText('score 45')).toBeInTheDocument();
41
+ expect(screen.getByText('eligible')).toBeInTheDocument();
42
+ expect(screen.getByText('ineligible')).toBeInTheDocument();
43
+ // Boolean-true factors render as bare chips; others as key: value.
44
+ expect(screen.getByText('weeklyAvailable')).toBeInTheDocument();
45
+ expect(screen.getByText('projectQualification: expert')).toBeInTheDocument();
46
+ expect(screen.getByText('projectQualification: none')).toBeInTheDocument();
47
+ expect(screen.getByText('openCases: 2')).toBeInTheDocument();
48
+ await expectNoA11yViolations(container);
49
+ });
50
+ it('fires onreassign with the specialist id', async () => {
51
+ const onreassign = vi.fn();
52
+ const { container } = render(RoutingRationale, {
53
+ props: { ranking: [rankedView()], onreassign },
54
+ });
55
+ await userEvent.click(screen.getByRole('button', { name: 'Reassign to Ada Lovelace' }));
56
+ expect(onreassign).toHaveBeenCalledWith('spec-1');
57
+ await expectNoA11yViolations(container);
58
+ });
59
+ it('renders no reassign action without an onreassign handler', () => {
60
+ render(RoutingRationale, { props: { ranking: [rankedView()] } });
61
+ expect(screen.queryByRole('button')).not.toBeInTheDocument();
62
+ });
63
+ it('shows the empty message for an empty ranking', () => {
64
+ render(RoutingRationale, {
65
+ props: { ranking: [], emptyMessage: 'Nobody to rank' },
66
+ });
67
+ expect(screen.getByText('Nobody to rank')).toBeInTheDocument();
68
+ });
69
+ });
@@ -0,0 +1,84 @@
1
+ // @vitest-environment jsdom
2
+ /**
3
+ * Component coverage for TargetList via the shared S11 harness (#1416).
4
+ */
5
+ import { expectNoA11yViolations, render, screen, } from '@happyvertical/smrt-vitest/svelte';
6
+ import { describe, expect, it } from 'vitest';
7
+ import TargetList from '../TargetList.svelte';
8
+ function targetView(overrides = {}) {
9
+ return {
10
+ id: 'target-1',
11
+ targetType: 'acknowledgement',
12
+ cycle: 0,
13
+ status: 'pending',
14
+ severity: 'sev2',
15
+ baseMinutes: 30,
16
+ startedAt: '2026-01-05T10:00:00.000Z',
17
+ dueAt: '2026-01-05T10:30:00.000Z',
18
+ satisfiedAt: null,
19
+ breachedAt: null,
20
+ paused: false,
21
+ ...overrides,
22
+ };
23
+ }
24
+ describe('TargetList', () => {
25
+ it('renders target type, due time, and status badge', async () => {
26
+ const { container } = render(TargetList, {
27
+ props: {
28
+ targets: [
29
+ targetView(),
30
+ targetView({
31
+ id: 'target-2',
32
+ targetType: 'resolution',
33
+ status: 'satisfied',
34
+ satisfiedAt: '2026-01-05T11:00:00.000Z',
35
+ }),
36
+ ],
37
+ },
38
+ });
39
+ expect(screen.getByText('acknowledgement')).toBeInTheDocument();
40
+ expect(screen.getByText('resolution')).toBeInTheDocument();
41
+ expect(screen.getAllByText(/due /)).toHaveLength(2);
42
+ expect(screen.getByText('pending')).toBeInTheDocument();
43
+ expect(screen.getByText('satisfied')).toBeInTheDocument();
44
+ await expectNoA11yViolations(container);
45
+ });
46
+ it('shows the paused indicator and the recurrence cycle', async () => {
47
+ const { container } = render(TargetList, {
48
+ props: {
49
+ targets: [
50
+ targetView({
51
+ id: 'target-3',
52
+ targetType: 'update',
53
+ status: 'paused',
54
+ paused: true,
55
+ cycle: 2,
56
+ }),
57
+ ],
58
+ },
59
+ });
60
+ expect(screen.getByText('paused', { selector: '.target-list-paused' })).toBeInTheDocument();
61
+ expect(screen.getByText('cycle 2')).toBeInTheDocument();
62
+ await expectNoA11yViolations(container);
63
+ });
64
+ it('renders a breached clock with the error badge label', () => {
65
+ render(TargetList, {
66
+ props: {
67
+ targets: [
68
+ targetView({
69
+ id: 'target-4',
70
+ status: 'breached',
71
+ breachedAt: '2026-01-05T11:00:00.000Z',
72
+ }),
73
+ ],
74
+ },
75
+ });
76
+ expect(screen.getByText('breached')).toBeInTheDocument();
77
+ });
78
+ it('shows the empty message when there are no targets', () => {
79
+ render(TargetList, {
80
+ props: { targets: [], emptyMessage: 'No clocks running' },
81
+ });
82
+ expect(screen.getByText('No clocks running')).toBeInTheDocument();
83
+ });
84
+ });