@littlebearapps/platform-admin-sdk 2.1.0 → 2.2.0

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 (115) hide show
  1. package/README.md +2 -5
  2. package/dist/templates.d.ts +1 -1
  3. package/dist/templates.js +121 -3
  4. package/package.json +1 -1
  5. package/templates/full/dashboard/src/components/notifications/NotificationDropdown.tsx +130 -0
  6. package/templates/full/dashboard/src/components/notifications/NotificationItem.tsx +264 -0
  7. package/templates/full/dashboard/src/components/patterns/PatternInfoButton.tsx +60 -0
  8. package/templates/full/dashboard/src/components/reports/FeatureUsageReport.tsx +339 -0
  9. package/templates/full/dashboard/src/components/search/SearchResultGroup.tsx +46 -0
  10. package/templates/full/dashboard/src/components/search/SearchResultItem.tsx +212 -0
  11. package/templates/full/dashboard/src/pages/api/patterns/[id]/approve.ts +49 -0
  12. package/templates/full/dashboard/src/pages/api/patterns/[id]/reject.ts +50 -0
  13. package/templates/full/dashboard/src/pages/api/reports/digests/stats.ts +38 -0
  14. package/templates/full/dashboard/src/pages/api/reports/digests.ts +39 -0
  15. package/templates/full/dashboard/src/pages/api/search/reindex/[type].ts +56 -0
  16. package/templates/full/dashboard/src/pages/api/test-reports/[id].ts +102 -0
  17. package/templates/full/dashboard/src/pages/feedback.astro +365 -0
  18. package/templates/full/dashboard/src/pages/kiosk.astro +206 -0
  19. package/templates/full/dashboard/src/pages/map.astro +561 -0
  20. package/templates/full/dashboard/src/pages/revenue.astro +72 -0
  21. package/templates/full/dashboard/src/pages/tests.astro +431 -0
  22. package/templates/full/scripts/ops/audit-cost-anomaly.ts +430 -0
  23. package/templates/full/scripts/ops/verify-account-total.ts +256 -0
  24. package/templates/full/tests/integration/feedback-schema.test.ts +361 -0
  25. package/templates/full/tests/integration/r2-archive.test.ts +108 -0
  26. package/templates/shared/.github/workflows/dependabot-automerge.yml +41 -0
  27. package/templates/shared/.github/workflows/validate-controls.yml +27 -0
  28. package/templates/shared/dashboard/src/components/Breadcrumbs.astro +101 -0
  29. package/templates/shared/dashboard/src/components/EmptyState.astro +46 -0
  30. package/templates/shared/dashboard/src/components/ErrorBoundary.astro +79 -0
  31. package/templates/shared/dashboard/src/components/LoadingSkeleton.astro +105 -0
  32. package/templates/shared/dashboard/src/components/PageShell.astro +72 -0
  33. package/templates/shared/dashboard/src/components/SkipLinks.astro +22 -0
  34. package/templates/shared/dashboard/src/components/Toast.astro +170 -0
  35. package/templates/shared/dashboard/src/components/ToastContainer.astro +156 -0
  36. package/templates/shared/dashboard/src/components/costs/ProviderCostsGrid.tsx +401 -0
  37. package/templates/shared/dashboard/src/components/costs/index.ts +4 -0
  38. package/templates/shared/dashboard/src/components/overview/AlertBanner.tsx +94 -0
  39. package/templates/shared/dashboard/src/components/overview/index.ts +9 -0
  40. package/templates/shared/dashboard/src/components/resources/CostChart.tsx +170 -0
  41. package/templates/shared/dashboard/src/components/resources/ProviderCard.tsx +272 -0
  42. package/templates/shared/dashboard/src/components/resources/ProviderDetail.tsx +293 -0
  43. package/templates/shared/dashboard/src/components/settings/SettingsCard.astro +102 -0
  44. package/templates/shared/dashboard/src/components/usage/AllowanceGauge.astro +170 -0
  45. package/templates/shared/dashboard/src/components/usage/AnomalyAlerts.astro +633 -0
  46. package/templates/shared/dashboard/src/components/usage/BillingCycleCountdown.astro +192 -0
  47. package/templates/shared/dashboard/src/components/usage/BurnRateHero.astro +539 -0
  48. package/templates/shared/dashboard/src/components/usage/CircuitBreakerEventLog.astro +542 -0
  49. package/templates/shared/dashboard/src/components/usage/CircuitBreakerPanel.tsx +292 -0
  50. package/templates/shared/dashboard/src/components/usage/CircuitBreakerStatus.astro +669 -0
  51. package/templates/shared/dashboard/src/components/usage/CompactThresholdBanner.astro +531 -0
  52. package/templates/shared/dashboard/src/components/usage/ComparisonModeSelector.astro +651 -0
  53. package/templates/shared/dashboard/src/components/usage/CostBreakdownChart.astro +381 -0
  54. package/templates/shared/dashboard/src/components/usage/CostBreakdownTable.astro +210 -0
  55. package/templates/shared/dashboard/src/components/usage/CostDataTable.astro +0 -0
  56. package/templates/shared/dashboard/src/components/usage/CostDonutChart.astro +311 -0
  57. package/templates/shared/dashboard/src/components/usage/DailyCostChart.astro +632 -0
  58. package/templates/shared/dashboard/src/components/usage/ExportButton.astro +114 -0
  59. package/templates/shared/dashboard/src/components/usage/FeatureBudgetsTable.astro +872 -0
  60. package/templates/shared/dashboard/src/components/usage/FilterBar.astro +190 -0
  61. package/templates/shared/dashboard/src/components/usage/FilterToggles.astro +175 -0
  62. package/templates/shared/dashboard/src/components/usage/GitHubUsageCard.astro +537 -0
  63. package/templates/shared/dashboard/src/components/usage/OverageCostCard.astro +212 -0
  64. package/templates/shared/dashboard/src/components/usage/PlanUtilizationCard.astro +193 -0
  65. package/templates/shared/dashboard/src/components/usage/ProjectCard.astro +640 -0
  66. package/templates/shared/dashboard/src/components/usage/ProjectCardsGrid.astro +272 -0
  67. package/templates/shared/dashboard/src/components/usage/ResourceSearch.astro +279 -0
  68. package/templates/shared/dashboard/src/components/usage/ServiceUtilizationList.astro +604 -0
  69. package/templates/shared/dashboard/src/components/usage/SparklineCard.astro +399 -0
  70. package/templates/shared/dashboard/src/components/usage/StatsHero.astro +600 -0
  71. package/templates/shared/dashboard/src/components/usage/TableFilters.astro +1033 -0
  72. package/templates/shared/dashboard/src/components/usage/ThresholdAlert.astro +271 -0
  73. package/templates/shared/dashboard/src/components/usage/ThresholdSettings.astro +618 -0
  74. package/templates/shared/dashboard/src/components/usage/TopSpenderCard.astro +170 -0
  75. package/templates/shared/dashboard/src/components/usage/UnifiedResourceTable.astro +1737 -0
  76. package/templates/shared/dashboard/src/components/usage/UsageCard.astro +135 -0
  77. package/templates/shared/dashboard/src/components/usage/UsageHealthBanner.astro +387 -0
  78. package/templates/shared/dashboard/src/components/usage/UtilizationBar.astro +159 -0
  79. package/templates/shared/dashboard/src/components/usage/WorkersBreakdownTable.astro +659 -0
  80. package/templates/shared/dashboard/src/components/usage/daily/CostChart.astro +461 -0
  81. package/templates/shared/dashboard/src/components/usage/daily/CostTable.astro +946 -0
  82. package/templates/shared/dashboard/src/components/usage/daily/DailyOverview.astro +1079 -0
  83. package/templates/shared/dashboard/src/components/usage/design-tokens.ts +187 -0
  84. package/templates/shared/dashboard/src/components/usage/filters/InlineDateRange.astro +285 -0
  85. package/templates/shared/dashboard/src/components/usage/filters/PeriodButtons.astro +157 -0
  86. package/templates/shared/dashboard/src/components/usage/filters/ProjectSelect.astro +284 -0
  87. package/templates/shared/dashboard/src/components/usage/scripts/ai-tab-controller.ts +419 -0
  88. package/templates/shared/dashboard/src/components/usage/scripts/constants.ts +60 -0
  89. package/templates/shared/dashboard/src/components/usage/scripts/formatters.ts +62 -0
  90. package/templates/shared/dashboard/src/components/usage/scripts/overview-controller.ts +1633 -0
  91. package/templates/shared/dashboard/src/components/usage/scripts/resource-table-builder.ts +294 -0
  92. package/templates/shared/dashboard/src/components/usage/scripts/tabs-filters-controller.ts +464 -0
  93. package/templates/shared/dashboard/src/components/usage/state/index.ts +55 -0
  94. package/templates/shared/dashboard/src/components/usage/state/usageActions.ts +439 -0
  95. package/templates/shared/dashboard/src/components/usage/state/usageStore.ts +376 -0
  96. package/templates/shared/dashboard/src/components/usage/types.ts +283 -0
  97. package/templates/shared/dashboard/src/components/usage/usage-colors.ts +292 -0
  98. package/templates/shared/dashboard/src/pages/api/usage/ai-models.ts +235 -0
  99. package/templates/shared/dashboard/src/pages/api/usage/billing-context.ts +296 -0
  100. package/templates/shared/scripts/test-telemetry-flow.ts +464 -0
  101. package/templates/shared/tests/e2e/usage-export.test.ts +784 -0
  102. package/templates/shared/tests/e2e/usage-mobile.test.ts +531 -0
  103. package/templates/standard/dashboard/src/components/errors/PriorityBadge.astro +27 -0
  104. package/templates/standard/dashboard/src/components/infrastructure/HealthchecksStatus.tsx +293 -0
  105. package/templates/standard/dashboard/src/components/infrastructure/InfrastructureTabs.tsx +268 -0
  106. package/templates/standard/dashboard/src/pages/analytics.astro +64 -0
  107. package/templates/standard/dashboard/src/pages/api/infrastructure/alerts.ts +85 -0
  108. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks/[id]/flips.ts +110 -0
  109. package/templates/standard/dashboard/src/pages/api/infrastructure/healthchecks.ts +101 -0
  110. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime/[id]/response-times.ts +121 -0
  111. package/templates/standard/dashboard/src/pages/api/infrastructure/uptime.ts +89 -0
  112. package/templates/standard/dashboard/src/pages/api/test/service-auth.ts +178 -0
  113. package/templates/standard/tests/integration/connectors.test.ts +241 -0
  114. package/templates/standard/tests/integration/github-monitor.test.ts +143 -0
  115. package/templates/standard/tests/integration/ingestion.test.ts +211 -0
@@ -0,0 +1,531 @@
1
+ /**
2
+ * Usage Dashboard - Mobile Responsive Tests
3
+ *
4
+ * Tests mobile-specific behaviour of the Usage Dashboard:
5
+ * - Card view data transformation
6
+ * - Mobile layout data structure
7
+ * - Touch-friendly element properties
8
+ * - Responsive breakpoint logic
9
+ *
10
+ * Note: These tests verify data and logic for mobile views.
11
+ * Visual/CSS testing would require Playwright with browser context.
12
+ *
13
+ * @module tests/e2e/usage-mobile
14
+ * @created 2026-01-05
15
+ * @task task-17.24 - Mobile responsive tests
16
+ */
17
+
18
+ import { describe, it, expect } from 'vitest';
19
+ import {
20
+ type UnifiedResource,
21
+ type ResourceType,
22
+ type ResourceStatus,
23
+ RESOURCE_TYPE_ICONS,
24
+ RESOURCE_TYPE_LABELS,
25
+ STATUS_COLOURS,
26
+ formatCurrency,
27
+ formatDeltaPct,
28
+ getDeltaClass,
29
+ } from '../../dashboard/src/components/usage/types';
30
+ import { filterResources, sortResources } from '../../dashboard/src/components/usage/transformers';
31
+
32
+ // Test data for mobile view scenarios
33
+ const createMobileTestResources = (): UnifiedResource[] => [
34
+ {
35
+ id: 'worker-brand-copilot-api',
36
+ name: 'brand-copilot-api',
37
+ type: 'worker',
38
+ project: 'brand-copilot',
39
+ usage: { value: 100000, unit: 'requests', formatted: '100K' },
40
+ costCurrent: 5.0,
41
+ costPrior: 4.0,
42
+ costDelta: 1.0,
43
+ costDeltaPct: 25,
44
+ status: 'healthy',
45
+ },
46
+ {
47
+ id: 'd1-brand-copilot-db',
48
+ name: 'brand-copilot-db',
49
+ type: 'd1',
50
+ project: 'brand-copilot',
51
+ usage: { value: 500000, unit: 'rows read', formatted: '500K' },
52
+ costCurrent: 2.5,
53
+ costPrior: 2.0,
54
+ costDelta: 0.5,
55
+ costDeltaPct: 25,
56
+ status: 'warning',
57
+ },
58
+ {
59
+ id: 'kv-platform-cache',
60
+ name: 'platform-cache',
61
+ type: 'kv',
62
+ project: 'platform',
63
+ usage: { value: 50000, unit: 'operations', formatted: '50K' },
64
+ costCurrent: 1.0,
65
+ costPrior: 0,
66
+ costDelta: 1.0,
67
+ costDeltaPct: 'NEW' as const,
68
+ status: 'healthy',
69
+ },
70
+ {
71
+ id: 'r2-ai-logs',
72
+ name: 'brand-copilot-ai-logs',
73
+ type: 'r2',
74
+ project: 'brand-copilot',
75
+ // Uses decimal GB (1 GB = 1,000,000,000 bytes) to match Cloudflare billing
76
+ usage: { value: 1000000000, unit: 'bytes', formatted: '1.00 GB' },
77
+ costCurrent: 3.0,
78
+ costPrior: 3.5,
79
+ costDelta: -0.5,
80
+ costDeltaPct: -14.3,
81
+ status: 'healthy',
82
+ },
83
+ {
84
+ id: 'worker-critical-service',
85
+ name: 'critical-service',
86
+ type: 'worker',
87
+ project: 'platform',
88
+ usage: { value: 25000, unit: 'requests', formatted: '25K' },
89
+ costCurrent: 8.5,
90
+ costPrior: 5.0,
91
+ costDelta: 3.5,
92
+ costDeltaPct: 70,
93
+ status: 'critical',
94
+ },
95
+ ];
96
+
97
+ describe('Mobile Responsive Tests', () => {
98
+ describe('Resource Type Icons and Labels', () => {
99
+ it('provides icons for all resource types', () => {
100
+ const resourceTypes: ResourceType[] = [
101
+ 'worker',
102
+ 'd1',
103
+ 'kv',
104
+ 'r2',
105
+ 'vectorize',
106
+ 'pages',
107
+ 'queues',
108
+ 'workflows',
109
+ 'do',
110
+ 'ai-gateway',
111
+ ];
112
+
113
+ resourceTypes.forEach((type) => {
114
+ expect(RESOURCE_TYPE_ICONS[type]).toBeDefined();
115
+ expect(typeof RESOURCE_TYPE_ICONS[type]).toBe('string');
116
+ expect(RESOURCE_TYPE_ICONS[type].length).toBeGreaterThan(0);
117
+ });
118
+ });
119
+
120
+ it('provides labels for all resource types', () => {
121
+ const resourceTypes: ResourceType[] = [
122
+ 'worker',
123
+ 'd1',
124
+ 'kv',
125
+ 'r2',
126
+ 'vectorize',
127
+ 'pages',
128
+ 'queues',
129
+ 'workflows',
130
+ 'do',
131
+ 'ai-gateway',
132
+ ];
133
+
134
+ resourceTypes.forEach((type) => {
135
+ expect(RESOURCE_TYPE_LABELS[type]).toBeDefined();
136
+ expect(typeof RESOURCE_TYPE_LABELS[type]).toBe('string');
137
+ expect(RESOURCE_TYPE_LABELS[type].length).toBeGreaterThan(0);
138
+ });
139
+ });
140
+
141
+ it('labels are human-readable (not raw type names)', () => {
142
+ expect(RESOURCE_TYPE_LABELS['d1']).toBe('D1');
143
+ expect(RESOURCE_TYPE_LABELS['kv']).toBe('KV');
144
+ expect(RESOURCE_TYPE_LABELS['r2']).toBe('R2');
145
+ expect(RESOURCE_TYPE_LABELS['do']).toBe('Durable Objects');
146
+ expect(RESOURCE_TYPE_LABELS['ai-gateway']).toBe('AI Gateway');
147
+ });
148
+ });
149
+
150
+ describe('Status Colours', () => {
151
+ it('provides colours for all status levels', () => {
152
+ const statuses: ResourceStatus[] = ['healthy', 'warning', 'high', 'critical'];
153
+
154
+ statuses.forEach((status) => {
155
+ expect(STATUS_COLOURS[status]).toBeDefined();
156
+ expect(STATUS_COLOURS[status]).toMatch(/^#[0-9A-Fa-f]{6}$/);
157
+ });
158
+ });
159
+
160
+ it('uses appropriate colour severity', () => {
161
+ // Healthy should be green-ish
162
+ expect(STATUS_COLOURS.healthy).toBe('#10B981');
163
+
164
+ // Warning should be yellow/amber
165
+ expect(STATUS_COLOURS.warning).toBe('#F59E0B');
166
+
167
+ // High should be orange
168
+ expect(STATUS_COLOURS.high).toBe('#F97316');
169
+
170
+ // Critical should be red
171
+ expect(STATUS_COLOURS.critical).toBe('#EF4444');
172
+ });
173
+ });
174
+
175
+ describe('Currency Formatting for Mobile', () => {
176
+ it('formats zero as $0.00', () => {
177
+ expect(formatCurrency(0)).toBe('$0.00');
178
+ });
179
+
180
+ it('formats small amounts with < symbol', () => {
181
+ expect(formatCurrency(0.005)).toBe('< $0.01');
182
+ expect(formatCurrency(0.001)).toBe('< $0.01');
183
+ });
184
+
185
+ it('formats normal amounts with 2 decimal places', () => {
186
+ expect(formatCurrency(5)).toBe('$5.00');
187
+ expect(formatCurrency(5.5)).toBe('$5.50');
188
+ expect(formatCurrency(5.556)).toBe('$5.56');
189
+ });
190
+
191
+ it('keeps amounts compact for mobile display', () => {
192
+ // All formatted values should be reasonably short
193
+ const testAmounts = [0, 0.001, 1.5, 10.99, 100.5, 999.99];
194
+ testAmounts.forEach((amount) => {
195
+ const formatted = formatCurrency(amount);
196
+ expect(formatted.length).toBeLessThan(15);
197
+ });
198
+ });
199
+ });
200
+
201
+ describe('Delta Percentage Formatting for Mobile', () => {
202
+ it('formats NEW as readable string', () => {
203
+ expect(formatDeltaPct('NEW')).toBe('NEW');
204
+ });
205
+
206
+ it('formats null as em dash', () => {
207
+ expect(formatDeltaPct(null)).toBe('—');
208
+ });
209
+
210
+ it('formats zero without sign', () => {
211
+ expect(formatDeltaPct(0)).toBe('0%');
212
+ });
213
+
214
+ it('formats positive with plus sign', () => {
215
+ expect(formatDeltaPct(25)).toBe('+25.0%');
216
+ expect(formatDeltaPct(100)).toBe('+100.0%');
217
+ });
218
+
219
+ it('formats negative with minus sign', () => {
220
+ expect(formatDeltaPct(-15)).toBe('-15.0%');
221
+ expect(formatDeltaPct(-50)).toBe('-50.0%');
222
+ });
223
+
224
+ it('outputs compact strings for mobile', () => {
225
+ const testValues = [0, 25, -25, 100, -100, 'NEW' as const, null];
226
+ testValues.forEach((value) => {
227
+ const formatted = formatDeltaPct(value);
228
+ expect(formatted.length).toBeLessThan(10);
229
+ });
230
+ });
231
+ });
232
+
233
+ describe('Delta Class for Visual Styling', () => {
234
+ it('returns delta-new for NEW resources', () => {
235
+ expect(getDeltaClass('NEW')).toBe('delta-new');
236
+ });
237
+
238
+ it('returns delta-neutral for zero or null', () => {
239
+ expect(getDeltaClass(0)).toBe('delta-neutral');
240
+ expect(getDeltaClass(null)).toBe('delta-neutral');
241
+ });
242
+
243
+ it('returns delta-up for positive changes', () => {
244
+ expect(getDeltaClass(25)).toBe('delta-up');
245
+ expect(getDeltaClass(100)).toBe('delta-up');
246
+ expect(getDeltaClass(0.1)).toBe('delta-up');
247
+ });
248
+
249
+ it('returns delta-down for negative changes', () => {
250
+ expect(getDeltaClass(-10)).toBe('delta-down');
251
+ expect(getDeltaClass(-50)).toBe('delta-down');
252
+ expect(getDeltaClass(-0.1)).toBe('delta-down');
253
+ });
254
+ });
255
+
256
+ describe('Mobile Card Data Structure', () => {
257
+ it('all resources have required mobile card fields', () => {
258
+ const resources = createMobileTestResources();
259
+
260
+ resources.forEach((resource) => {
261
+ // Header row data
262
+ expect(resource.name).toBeDefined();
263
+ expect(typeof resource.name).toBe('string');
264
+ expect(resource.type).toBeDefined();
265
+ expect(resource.status).toBeDefined();
266
+
267
+ // Body metrics
268
+ expect(resource.project).toBeDefined();
269
+ expect(resource.usage).toBeDefined();
270
+ expect(resource.usage.formatted).toBeDefined();
271
+ expect(resource.usage.unit).toBeDefined();
272
+ expect(resource.costCurrent).toBeDefined();
273
+ expect(resource.costDeltaPct).toBeDefined();
274
+ });
275
+ });
276
+
277
+ it('resource names are not too long for mobile', () => {
278
+ const resources = createMobileTestResources();
279
+
280
+ resources.forEach((resource) => {
281
+ // Names should be reasonable length (can be truncated with ellipsis)
282
+ // but very long names could cause layout issues
283
+ expect(resource.name.length).toBeLessThan(100);
284
+ });
285
+ });
286
+
287
+ it('formatted usage values are compact', () => {
288
+ const resources = createMobileTestResources();
289
+
290
+ resources.forEach((resource) => {
291
+ // Formatted values should be human-readable and compact
292
+ expect(resource.usage.formatted.length).toBeLessThan(20);
293
+ });
294
+ });
295
+ });
296
+
297
+ describe('Mobile Sorting Behaviour', () => {
298
+ it('sorts by cost for easy comparison', () => {
299
+ const resources = createMobileTestResources();
300
+ const sorted = sortResources(resources, 'costCurrent', 'desc');
301
+
302
+ // Highest cost first
303
+ expect(sorted[0].costCurrent).toBe(8.5);
304
+ expect(sorted[sorted.length - 1].costCurrent).toBe(1.0);
305
+ });
306
+
307
+ it('sorts by status severity for quick triage', () => {
308
+ const resources = createMobileTestResources();
309
+ const sorted = sortResources(resources, 'status', 'desc');
310
+
311
+ // Critical first
312
+ expect(sorted[0].status).toBe('critical');
313
+ });
314
+
315
+ it('sorts by name alphabetically', () => {
316
+ const resources = createMobileTestResources();
317
+ const sorted = sortResources(resources, 'name', 'asc');
318
+
319
+ // Alphabetical order
320
+ for (let i = 0; i < sorted.length - 1; i++) {
321
+ expect(sorted[i].name.localeCompare(sorted[i + 1].name)).toBeLessThanOrEqual(0);
322
+ }
323
+ });
324
+
325
+ it('handles NEW in delta percentage sorting', () => {
326
+ const resources = createMobileTestResources();
327
+ const sorted = sortResources(resources, 'costDeltaPct', 'desc');
328
+
329
+ // NEW should appear first in descending order
330
+ expect(sorted[0].costDeltaPct).toBe('NEW');
331
+ });
332
+ });
333
+
334
+ describe('Mobile Filtering Behaviour', () => {
335
+ it('filters by project for focused view', () => {
336
+ const resources = createMobileTestResources();
337
+ const filtered = filterResources(resources, { project: 'brand-copilot' });
338
+
339
+ expect(filtered.length).toBe(3);
340
+ expect(filtered.every((r) => r.project === 'brand-copilot')).toBe(true);
341
+ });
342
+
343
+ it('filters by service type', () => {
344
+ const resources = createMobileTestResources();
345
+ const filtered = filterResources(resources, { serviceTypes: ['worker'] });
346
+
347
+ expect(filtered.length).toBe(2);
348
+ expect(filtered.every((r) => r.type === 'worker')).toBe(true);
349
+ });
350
+
351
+ it('filters by non-zero cost for mobile billing focus', () => {
352
+ const resources = createMobileTestResources();
353
+ const filtered = filterResources(resources, { nonZeroCost: true });
354
+
355
+ expect(filtered.length).toBe(5); // All have costs > 0
356
+ expect(filtered.every((r) => r.costCurrent > 0)).toBe(true);
357
+ });
358
+
359
+ it('filters by only changed resources', () => {
360
+ const resources = createMobileTestResources();
361
+ const filtered = filterResources(resources, { onlyChanged: true });
362
+
363
+ // Resources with > 5% change or NEW
364
+ filtered.forEach((r) => {
365
+ if (typeof r.costDeltaPct === 'number') {
366
+ expect(Math.abs(r.costDeltaPct)).toBeGreaterThan(5);
367
+ } else {
368
+ expect(r.costDeltaPct).toBe('NEW');
369
+ }
370
+ });
371
+ });
372
+
373
+ it('supports search query for quick find', () => {
374
+ const resources = createMobileTestResources();
375
+ const filtered = filterResources(resources, { searchQuery: 'brand' });
376
+
377
+ expect(filtered.length).toBe(3);
378
+ expect(filtered.every((r) => r.name.toLowerCase().includes('brand'))).toBe(true);
379
+ });
380
+ });
381
+
382
+ describe('Mobile Touch Target Requirements', () => {
383
+ // Note: These are data validation tests. CSS would need browser testing.
384
+ // The component uses tabindex=0 on rows, making them focusable/clickable.
385
+
386
+ it('resources have unique IDs for click handling', () => {
387
+ const resources = createMobileTestResources();
388
+ const ids = resources.map((r) => r.id);
389
+ const uniqueIds = new Set(ids);
390
+
391
+ expect(uniqueIds.size).toBe(ids.length);
392
+ });
393
+
394
+ it('resources have type for icon display', () => {
395
+ const resources = createMobileTestResources();
396
+
397
+ resources.forEach((resource) => {
398
+ expect(RESOURCE_TYPE_ICONS[resource.type]).toBeDefined();
399
+ });
400
+ });
401
+
402
+ it('resources have status for colour indicator', () => {
403
+ const resources = createMobileTestResources();
404
+
405
+ resources.forEach((resource) => {
406
+ expect(STATUS_COLOURS[resource.status]).toBeDefined();
407
+ });
408
+ });
409
+ });
410
+
411
+ describe('Mobile Breakpoint Data Compatibility', () => {
412
+ // Verify data works for both table and card views
413
+
414
+ it('all fields used in table are available for cards', () => {
415
+ const resources = createMobileTestResources();
416
+ const tableFields = [
417
+ 'name',
418
+ 'type',
419
+ 'project',
420
+ 'usage',
421
+ 'costCurrent',
422
+ 'costDeltaPct',
423
+ 'status',
424
+ ];
425
+
426
+ resources.forEach((resource) => {
427
+ tableFields.forEach((field) => {
428
+ expect(resource).toHaveProperty(field);
429
+ });
430
+ });
431
+ });
432
+
433
+ it('usage has all subfields for mobile display', () => {
434
+ const resources = createMobileTestResources();
435
+
436
+ resources.forEach((resource) => {
437
+ expect(resource.usage).toHaveProperty('value');
438
+ expect(resource.usage).toHaveProperty('unit');
439
+ expect(resource.usage).toHaveProperty('formatted');
440
+ });
441
+ });
442
+ });
443
+
444
+ describe('Edge Cases for Mobile Display', () => {
445
+ it('handles empty resource list', () => {
446
+ const filtered = filterResources([], {});
447
+ expect(filtered).toHaveLength(0);
448
+
449
+ const sorted = sortResources([], 'costCurrent', 'desc');
450
+ expect(sorted).toHaveLength(0);
451
+ });
452
+
453
+ it('handles single resource', () => {
454
+ const resources = createMobileTestResources().slice(0, 1);
455
+
456
+ const sorted = sortResources(resources, 'costCurrent', 'desc');
457
+ expect(sorted).toHaveLength(1);
458
+ });
459
+
460
+ it('handles resource with null delta percentage', () => {
461
+ const resource: UnifiedResource = {
462
+ id: 'test',
463
+ name: 'test-resource',
464
+ type: 'worker',
465
+ project: 'test',
466
+ usage: { value: 100, unit: 'requests', formatted: '100' },
467
+ costCurrent: 1.0,
468
+ costPrior: 0,
469
+ costDelta: 1.0,
470
+ costDeltaPct: null,
471
+ status: 'healthy',
472
+ };
473
+
474
+ expect(formatDeltaPct(resource.costDeltaPct)).toBe('—');
475
+ expect(getDeltaClass(resource.costDeltaPct)).toBe('delta-neutral');
476
+ });
477
+
478
+ it('handles very long resource names', () => {
479
+ const longName = 'this-is-a-very-long-resource-name-that-might-overflow-on-mobile-devices';
480
+ const formatted = longName; // Component truncates with CSS text-overflow: ellipsis
481
+
482
+ expect(formatted.length).toBeGreaterThan(50); // Just verify long names are allowed
483
+ });
484
+
485
+ it('handles large cost values', () => {
486
+ const largeCost = 9999.99;
487
+ const formatted = formatCurrency(largeCost);
488
+
489
+ expect(formatted).toBe('$9999.99');
490
+ expect(formatted.length).toBeLessThan(15);
491
+ });
492
+
493
+ it('handles extreme percentage changes', () => {
494
+ expect(formatDeltaPct(500)).toBe('+500.0%');
495
+ expect(formatDeltaPct(-99)).toBe('-99.0%');
496
+ expect(formatDeltaPct(1000)).toBe('+1000.0%');
497
+ });
498
+ });
499
+
500
+ describe('Accessibility Data for Mobile', () => {
501
+ it('status values are screenreader-friendly', () => {
502
+ const statuses: ResourceStatus[] = ['healthy', 'warning', 'high', 'critical'];
503
+
504
+ statuses.forEach((status) => {
505
+ // Status values should be words, not codes
506
+ expect(status.length).toBeGreaterThan(2);
507
+ expect(/^[a-z]+$/i.test(status)).toBe(true);
508
+ });
509
+ });
510
+
511
+ it('type labels are screenreader-friendly', () => {
512
+ Object.values(RESOURCE_TYPE_LABELS).forEach((label) => {
513
+ // Labels should be readable words/phrases
514
+ expect(label.length).toBeGreaterThan(0);
515
+ expect(/^[A-Za-z0-9\s]+$/.test(label)).toBe(true);
516
+ });
517
+ });
518
+
519
+ it('formatted values include context', () => {
520
+ const resources = createMobileTestResources();
521
+
522
+ resources.forEach((resource) => {
523
+ // Formatted values should be numeric-looking for numbers
524
+ expect(resource.usage.formatted).toBeDefined();
525
+
526
+ // Units provide context
527
+ expect(resource.usage.unit.length).toBeGreaterThan(0);
528
+ });
529
+ });
530
+ });
531
+ });
@@ -0,0 +1,27 @@
1
+ ---
2
+ /**
3
+ * Priority Badge Component
4
+ * Displays P0-P4 priority with appropriate colour coding
5
+ */
6
+ interface Props {
7
+ priority: string;
8
+ size?: 'sm' | 'md';
9
+ }
10
+
11
+ const { priority, size = 'md' } = Astro.props;
12
+
13
+ const colours: Record<string, string> = {
14
+ P0: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-200 dark:border-red-800',
15
+ P1: 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 border-orange-200 dark:border-orange-800',
16
+ P2: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 border-yellow-200 dark:border-yellow-800',
17
+ P3: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 border-blue-200 dark:border-blue-800',
18
+ P4: 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-300 border-gray-200 dark:border-gray-800',
19
+ };
20
+
21
+ const sizeClasses = size === 'sm' ? 'px-1.5 py-0.5 text-xs' : 'px-2 py-1 text-sm';
22
+ const colourClass = colours[priority] || colours.P4;
23
+ ---
24
+
25
+ <span class={`inline-flex items-center font-semibold rounded border ${colourClass} ${sizeClasses}`}>
26
+ {priority}
27
+ </span>